too much for one commit

This commit is contained in:
Nils Blomgren
2026-05-13 14:01:32 +02:00
parent a96dd33039
commit 328d494530
394 changed files with 98104 additions and 78 deletions

View File

@@ -0,0 +1,185 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
public class AdoptClusterHandlerTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IAdoptionCheck> kyvernoCheck;
private readonly Mock<IAdoptionCheck> securityPoliciesCheck;
private readonly AdoptClusterHandler handler;
public AdoptClusterHandlerTests()
{
repository = new InMemoryClusterRepository();
kyvernoCheck = new Mock<IAdoptionCheck>();
securityPoliciesCheck = new Mock<IAdoptionCheck>();
kyvernoCheck.Setup(c => c.ComponentName).Returns("kyverno");
securityPoliciesCheck.Setup(c => c.ComponentName).Returns("security-policies");
List<IAdoptionCheck> checks = new() { kyvernoCheck.Object, securityPoliciesCheck.Object };
handler = new AdoptClusterHandler(repository, checks, NullLogger<AdoptClusterHandler>.Instance);
}
[Fact]
public async Task HandleAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange — Attempt to adopt a cluster that doesn't exist.
Guid unknownId = Guid.NewGuid();
// Act
Result<AdoptionReport> result = await handler.HandleAsync(unknownId);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_ClusterNotConnected_ReturnsFailure()
{
// Arrange — The cluster exists but is still Pending (not yet reachable).
KubernetesCluster cluster = KubernetesCluster.Register(
"dev-cluster", "https://k8s.dev:6443", "kubeconfig-data", Guid.NewGuid(), "dev-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act
Result<AdoptionReport> result = await handler.HandleAsync(cluster.Id);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_AllComponentsInstalled_ReturnsReadyReport()
{
// Arrange — A connected cluster where all components report Installed.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running: kyverno-admission-controller-0" },
new List<string>()));
securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.Installed,
new List<string> { "Policy present: deny-loadbalancer-services" },
new List<string>()));
// Act
Result<AdoptionReport> result = await handler.HandleAsync(cluster.Id);
// Assert — All components installed means Ready.
result.IsSuccess.Should().BeTrue();
result.Value!.Status.Should().Be(AdoptionReadiness.Ready);
result.Value.Components.Should().HaveCount(2);
result.Value.Components.Should().AllSatisfy(c => c.Status.Should().Be(ComponentStatus.Installed));
}
[Fact]
public async Task HandleAsync_OneComponentNotInstalled_ReturnsNotReady()
{
// Arrange — Kyverno is missing entirely while policies exist (impossible
// in practice, but tests the aggregation logic).
KubernetesCluster cluster = KubernetesCluster.Register(
"bare-cluster", "https://k8s.bare:6443", "kubeconfig-data", Guid.NewGuid(), "bare-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.NotInstalled,
new List<string>(),
new List<string> { "No Kyverno pods found" }));
securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.NotInstalled,
new List<string>(),
new List<string> { "restrict-image-registries", "deny-loadbalancer-services" }));
// Act
Result<AdoptionReport> result = await handler.HandleAsync(cluster.Id);
// Assert — Any component NotInstalled means the cluster is NotReady.
result.IsSuccess.Should().BeTrue();
result.Value!.Status.Should().Be(AdoptionReadiness.NotReady);
}
[Fact]
public async Task HandleAsync_OneComponentDegraded_ReturnsPartiallyReady()
{
// Arrange — Kyverno is installed but security policies are only partially deployed.
KubernetesCluster cluster = KubernetesCluster.Register(
"partial-cluster", "https://k8s.partial:6443", "kubeconfig-data", Guid.NewGuid(), "partial-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running: kyverno-admission-controller-0" },
new List<string>()));
securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.Degraded,
new List<string> { "Policy present: deny-loadbalancer-services" },
new List<string> { "restrict-image-registries", "require-seccomp-runtime-default" }));
// Act
Result<AdoptionReport> result = await handler.HandleAsync(cluster.Id);
// Assert — Degraded components mean PartiallyReady.
result.IsSuccess.Should().BeTrue();
result.Value!.Status.Should().Be(AdoptionReadiness.PartiallyReady);
}
[Fact]
public async Task HandleAsync_RunsAllChecksInParallel()
{
// Arrange — Verify that both checks are called for a connected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig-data", Guid.NewGuid(), "test-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed, new List<string>(), new List<string>()));
securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.Installed, new List<string>(), new List<string>()));
// Act
await handler.HandleAsync(cluster.Id);
// Assert — Both checks were invoked.
kyvernoCheck.Verify(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()), Times.Once);
securityPoliciesCheck.Verify(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -0,0 +1,197 @@
using EntKube.Clusters.Features.Certificates;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the Certificate Authority feature. Since the handler connects to
/// a real K8s cluster, these tests focus on the domain models, request validation,
/// and operation result patterns. Integration tests with a real cluster would go
/// in a separate test suite.
/// </summary>
public class CertificateAuthorityTests
{
// ─── CaOperationResult ────────────────────────────────────────────────
[Fact]
public void CaOperationResult_Ok_HasSuccessTrue()
{
// When we create a successful result, it should carry the message
// and have Success = true.
CaOperationResult result = CaOperationResult.Ok("CA created.", new List<string> { "Step 1" });
result.Success.Should().BeTrue();
result.Message.Should().Be("CA created.");
result.Actions.Should().ContainSingle().Which.Should().Be("Step 1");
}
[Fact]
public void CaOperationResult_Failure_HasSuccessFalse()
{
// A failure result should carry the error message with Success = false.
CaOperationResult result = CaOperationResult.Failure("Something went wrong.");
result.Success.Should().BeFalse();
result.Message.Should().Be("Something went wrong.");
result.Actions.Should().BeEmpty();
}
[Fact]
public void CaOperationResult_Ok_WithNoActions_DefaultsToEmptyList()
{
// When no actions are provided, the list should default to empty.
CaOperationResult result = CaOperationResult.Ok("Done.");
result.Actions.Should().NotBeNull();
result.Actions.Should().BeEmpty();
}
// ─── CertificateAuthorityInfo ─────────────────────────────────────────
[Fact]
public void CertificateAuthorityInfo_InternalCA_HasCorrectType()
{
// An internal CA should be classified as CaType.Internal and have
// no domain restrictions.
CertificateAuthorityInfo ca = new(
Name: "platform-internal-ca",
Type: CaType.Internal,
SecretName: "platform-internal-ca-secret",
Domains: new List<string>(),
IsExternal: false,
InTrustBundle: true,
Status: "Ready",
NotBefore: DateTimeOffset.UtcNow.AddYears(-1),
NotAfter: DateTimeOffset.UtcNow.AddYears(9),
Organization: "EntKube Platform");
ca.Type.Should().Be(CaType.Internal);
ca.Domains.Should().BeEmpty();
ca.IsExternal.Should().BeFalse();
ca.InTrustBundle.Should().BeTrue();
}
[Fact]
public void CertificateAuthorityInfo_DomainCA_HasDomains()
{
// A domain CA should carry the list of domains it's scoped to.
CertificateAuthorityInfo ca = new(
Name: "corp-ca",
Type: CaType.Domain,
SecretName: "corp-ca-secret",
Domains: new List<string> { "*.internal.corp.com", "*.svc.local" },
IsExternal: false,
InTrustBundle: true,
Status: "Ready",
NotBefore: DateTimeOffset.UtcNow,
NotAfter: DateTimeOffset.UtcNow.AddYears(5),
Organization: "Corp Inc");
ca.Type.Should().Be(CaType.Domain);
ca.Domains.Should().HaveCount(2);
ca.Domains.Should().Contain("*.internal.corp.com");
}
[Fact]
public void CertificateAuthorityInfo_ExternalCA_FlagIsSet()
{
// An imported external CA should have IsExternal = true.
CertificateAuthorityInfo ca = new(
Name: "digicert-ca",
Type: CaType.Domain,
SecretName: "digicert-ca-secret",
Domains: new List<string> { "*.example.com" },
IsExternal: true,
InTrustBundle: true,
Status: "Ready",
NotBefore: null,
NotAfter: null,
Organization: "DigiCert");
ca.IsExternal.Should().BeTrue();
}
[Fact]
public void CertificateAuthorityInfo_LetsEncrypt_HasAcmeType()
{
// A Let's Encrypt issuer is always external and publicly trusted.
CertificateAuthorityInfo ca = new(
Name: "letsencrypt-prod",
Type: CaType.LetsEncrypt,
SecretName: string.Empty,
Domains: new List<string>(),
IsExternal: true,
InTrustBundle: true,
Status: "Ready",
NotBefore: null,
NotAfter: null,
Organization: "Let's Encrypt");
ca.Type.Should().Be(CaType.LetsEncrypt);
ca.IsExternal.Should().BeTrue();
}
// ─── Request models ──────────────────────────────────────────────────
[Fact]
public void CreateInternalCARequest_DefaultValues_AreReasonable()
{
// The default values for an internal CA should be sensible defaults.
CreateInternalCARequest request = new("my-ca");
request.Name.Should().Be("my-ca");
request.Organization.Should().Be("EntKube Platform");
request.DurationDays.Should().Be(3650);
request.BundleName.Should().BeNull();
}
[Fact]
public void CreateDomainCARequest_WithDomains_CarriesThem()
{
// A domain CA request should carry the domains it covers.
CreateDomainCARequest request = new(
Name: "corp-ca",
Domains: new List<string> { "*.corp.com", "*.internal.corp.com" });
request.Domains.Should().HaveCount(2);
request.TlsCert.Should().BeNull();
request.TlsKey.Should().BeNull();
}
[Fact]
public void CreateDomainCARequest_WithImportedCert_HasTlsFields()
{
// An imported domain CA should carry the base64-encoded cert and key.
CreateDomainCARequest request = new(
Name: "imported-ca",
Domains: new List<string> { "*.example.com" },
TlsCert: "base64cert",
TlsKey: "base64key");
request.TlsCert.Should().Be("base64cert");
request.TlsKey.Should().Be("base64key");
}
// ─── CaType enum ─────────────────────────────────────────────────────
[Fact]
public void CaType_HasExpectedValues()
{
// The enum should have exactly three values.
Enum.GetValues<CaType>().Should().HaveCount(3);
Enum.GetValues<CaType>().Should().Contain(CaType.Internal);
Enum.GetValues<CaType>().Should().Contain(CaType.Domain);
Enum.GetValues<CaType>().Should().Contain(CaType.LetsEncrypt);
}
}

View File

@@ -0,0 +1,152 @@
using EntKube.Clusters.Infrastructure.Cleura;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class CleuraClientTests
{
[Fact]
public void ParseAuthResponse_ValidJson_ReturnsToken()
{
// The Cleura API returns a JSON body with result and token fields
// after a successful authentication POST.
string json = """{"result": "login_ok", "token": "vahkie7EiDaij7chegaitee2zohsh1oh"}""";
CleuraAuthResponse? response = System.Text.Json.JsonSerializer.Deserialize<CleuraAuthResponse>(json);
response.Should().NotBeNull();
response!.Result.Should().Be("login_ok");
response.Token.Should().Be("vahkie7EiDaij7chegaitee2zohsh1oh");
}
[Fact]
public void ParseDomainsResponse_ValidJson_ReturnsRegions()
{
// The domains endpoint returns a list of domains, each with a region identifier.
string json = """
{
"domains": [
{"name": "my-domain", "region": "Sto2", "id": "abc123"},
{"name": "my-domain", "region": "Fra1", "id": "def456"}
]
}
""";
CleuraDomainsResponse? response = System.Text.Json.JsonSerializer.Deserialize<CleuraDomainsResponse>(json,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
response.Should().NotBeNull();
response!.Domains.Should().HaveCount(2);
response.Domains[0].Region.Should().Be("Sto2");
response.Domains[1].Region.Should().Be("Fra1");
}
[Fact]
public void BuildAuthRequest_FormatsCorrectly()
{
// Verify the authentication request body matches Cleura's expected format.
CleuraAuthRequest request = new("my-user", "my-pass");
string json = System.Text.Json.JsonSerializer.Serialize(request);
json.Should().Contain("\"auth\"");
json.Should().Contain("\"login\"");
json.Should().Contain("\"password\"");
json.Should().Contain("my-user");
}
[Fact]
public void BuildAuthRequest_WithTwofaMethod_IncludesTwofaMethod()
{
// When a 2FA method is specified, the auth body should include
// the twofa_method field so Cleura selects that method.
CleuraAuthRequest request = new("my-user", "my-pass", "sms");
string json = System.Text.Json.JsonSerializer.Serialize(request);
json.Should().Contain("\"twofa_method\"");
json.Should().Contain("sms");
}
[Fact]
public void BuildAuthRequest_WithoutTwofaMethod_OmitsTwofaMethod()
{
// Without a 2FA method, the twofa_method field should not be present.
CleuraAuthRequest request = new("my-user", "my-pass");
string json = System.Text.Json.JsonSerializer.Serialize(request);
json.Should().NotContain("twofa_method");
}
[Fact]
public void ParseAuthResponse_TwoFactorOptions_IndicatesNeed()
{
// When 2FA is enabled but no code is provided, Cleura responds with
// twofactor_options listing available methods (sms, webauthn).
string json = """{"result": "twofactor_options", "token": "", "options": ["sms", "webauthn"]}""";
CleuraAuthResponse? response = System.Text.Json.JsonSerializer.Deserialize<CleuraAuthResponse>(json);
response.Should().NotBeNull();
response!.Result.Should().Be("twofactor_options");
response.Options.Should().Contain("sms");
response.Options.Should().Contain("webauthn");
}
[Fact]
public void ParseAuthResponse_TwoFactorRequired_ReturnsVerification()
{
// When a 2FA method is selected, Cleura responds with twofactor_required
// and a verification token for subsequent steps.
string json = """{"result": "twofactor_required", "token": "", "verification": "xe9hik8q6r"}""";
CleuraAuthResponse? response = System.Text.Json.JsonSerializer.Deserialize<CleuraAuthResponse>(json);
response.Should().NotBeNull();
response!.Result.Should().Be("twofactor_required");
response.Verification.Should().Be("xe9hik8q6r");
}
[Fact]
public void BuildRequest2faCode_FormatsCorrectly()
{
// The request2facode endpoint expects a specific JSON structure
// with the login and verification token from the prior step.
CleuraRequest2faCodeRequest request = new("JohnDoe", "xe9hik8q6r");
string json = System.Text.Json.JsonSerializer.Serialize(request);
json.Should().Contain("\"request2fa\"");
json.Should().Contain("\"login\"");
json.Should().Contain("\"verification\"");
json.Should().Contain("JohnDoe");
json.Should().Contain("xe9hik8q6r");
}
[Fact]
public void BuildVerify2fa_FormatsCorrectly()
{
// The verify2fa endpoint expects the login, verification, and integer code.
CleuraVerify2faRequest request = new("JohnDoe", "xe9hik8q6r", 123456);
string json = System.Text.Json.JsonSerializer.Serialize(request);
json.Should().Contain("\"verify2fa\"");
json.Should().Contain("\"login\"");
json.Should().Contain("\"verification\"");
json.Should().Contain("\"code\"");
json.Should().Contain("JohnDoe");
json.Should().Contain("xe9hik8q6r");
json.Should().Contain("123456");
}
}

View File

@@ -0,0 +1,116 @@
using EntKube.Clusters.Infrastructure.Cleura;
using FluentAssertions;
using System.Text.Json;
namespace EntKube.Clusters.Tests.Features;
public class CleuraObjectStorageTests
{
[Fact]
public void ParseKeystoneTokenResponse_ExtractsUserId()
{
// After authenticating with Keystone, the response includes the user ID
// which is needed to create EC2 credentials.
string json = """
{
"token": {
"user": {
"id": "abc123",
"name": "testuser",
"domain": { "id": "domainid", "name": "CCP_Domain_1_268" }
},
"catalog": [],
"expires_at": "2026-05-08T12:00:00Z"
}
}
""";
KeystoneTokenResponse? response = JsonSerializer.Deserialize<KeystoneTokenResponse>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
response.Should().NotBeNull();
response!.Token.User.Id.Should().Be("abc123");
response.Token.User.Name.Should().Be("testuser");
}
[Fact]
public void ParseEc2CredentialResponse_ExtractsAccessAndSecret()
{
// Creating EC2 credentials returns the access/secret key pair
// that serves as S3-compatible credentials.
string json = """
{
"credential": {
"access": "AKIAIOSFODNN7EXAMPLE",
"secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"user_id": "abc123",
"project_id": "project456",
"tenant_id": "project456"
}
}
""";
Ec2CredentialResponse? response = JsonSerializer.Deserialize<Ec2CredentialResponse>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
response.Should().NotBeNull();
response!.Credential.Access.Should().Be("AKIAIOSFODNN7EXAMPLE");
response.Credential.Secret.Should().Be("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
response.Credential.ProjectId.Should().Be("project456");
}
[Fact]
public void S3EndpointUrl_ForRegion_FormatsCorrectly()
{
// Cleura S3 endpoints follow the pattern https://s3-{region}.citycloud.com
string endpoint = CleuraS3Client.GetS3Endpoint("Sto2");
endpoint.Should().Be("https://s3-sto2.citycloud.com");
}
[Fact]
public void S3EndpointUrl_ForFra1_FormatsCorrectly()
{
string endpoint = CleuraS3Client.GetS3Endpoint("Fra1");
endpoint.Should().Be("https://s3-fra1.citycloud.com");
}
[Fact]
public void ParseEc2CredentialListResponse_ReturnsMultiple()
{
// Listing credentials returns all existing EC2 credential pairs for the user/project.
string json = """
{
"credentials": [
{
"access": "KEY1",
"secret": "SECRET1",
"user_id": "u1",
"project_id": "p1",
"tenant_id": "p1"
},
{
"access": "KEY2",
"secret": "SECRET2",
"user_id": "u1",
"project_id": "p2",
"tenant_id": "p2"
}
]
}
""";
Ec2CredentialListResponse? response = JsonSerializer.Deserialize<Ec2CredentialListResponse>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
response.Should().NotBeNull();
response!.Credentials.Should().HaveCount(2);
response.Credentials[0].Access.Should().Be("KEY1");
response.Credentials[1].ProjectId.Should().Be("p2");
}
}

View File

@@ -0,0 +1,239 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.ClusterHealth;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class ClusterHealthHandlerTests
{
private readonly FakePrometheusQueryClient fakeQueryClient;
private readonly FakeClusterRepository repository;
private readonly ClusterHealthHandler sut;
public ClusterHealthHandlerTests()
{
fakeQueryClient = new FakePrometheusQueryClient();
repository = new FakeClusterRepository();
sut = new ClusterHealthHandler(repository, fakeQueryClient);
}
[Fact]
public async Task HandleAsync_WithHealthyCluster_ReturnsAllMetrics()
{
// Arrange — a cluster with a Prometheus endpoint, all metrics looking good.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "prod", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus")
});
await repository.AddAsync(cluster);
fakeQueryClient.SetResults(new Dictionary<string, double>
{
["count(kube_node_info)"] = 3,
["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 3,
["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 42.5,
["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 68.2,
["sum(kube_pod_status_phase{phase=\"Running\"})"] = 45,
["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 2,
["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 0,
["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 3,
["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 0.1,
["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 0
});
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert — we get a complete health report.
result.IsSuccess.Should().BeTrue();
ClusterHealthReport report = result.Value!;
report.TotalNodes.Should().Be(3);
report.ReadyNodes.Should().Be(3);
report.CpuUtilizationPercent.Should().BeApproximately(42.5, 0.1);
report.MemoryUtilizationPercent.Should().BeApproximately(68.2, 0.1);
report.RunningPods.Should().Be(45);
report.PendingPods.Should().Be(2);
report.FailedPods.Should().Be(0);
report.ContainerRestartsLastHour.Should().Be(3);
report.ApiServerErrorRatePercent.Should().BeApproximately(0.1, 0.01);
report.NodesWithDiskPressure.Should().Be(0);
report.OverallStatus.Should().Be(ClusterHealthStatus.Healthy);
}
[Fact]
public async Task HandleAsync_WithNoPrometheusEndpoints_ReturnsFailure()
{
// Arrange — cluster without Prometheus discovered yet.
KubernetesCluster cluster = KubernetesCluster.Register(
"no-prom", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("No Prometheus endpoints");
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_WithDegradedMetrics_ReturnsDegradedStatus()
{
// Arrange — high CPU, some pending pods, and container restarts indicate degradation.
KubernetesCluster cluster = KubernetesCluster.Register(
"degraded-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus")
});
await repository.AddAsync(cluster);
fakeQueryClient.SetResults(new Dictionary<string, double>
{
["count(kube_node_info)"] = 3,
["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 2,
["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 85.0,
["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 78.0,
["sum(kube_pod_status_phase{phase=\"Running\"})"] = 40,
["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 8,
["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 2,
["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 15,
["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 3.0,
["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 0
});
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.OverallStatus.Should().Be(ClusterHealthStatus.Degraded);
}
[Fact]
public async Task HandleAsync_WithCriticalMetrics_ReturnsCriticalStatus()
{
// Arrange — nodes not ready and high API error rate = critical.
KubernetesCluster cluster = KubernetesCluster.Register(
"critical-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus")
});
await repository.AddAsync(cluster);
fakeQueryClient.SetResults(new Dictionary<string, double>
{
["count(kube_node_info)"] = 3,
["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 1,
["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 95.0,
["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 92.0,
["sum(kube_pod_status_phase{phase=\"Running\"})"] = 10,
["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 20,
["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 15,
["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 50,
["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 12.0,
["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 2
});
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.OverallStatus.Should().Be(ClusterHealthStatus.Critical);
}
}
/// <summary>
/// Fake Prometheus query client for testing — returns preconfigured values
/// for specific PromQL queries without needing a real Prometheus instance.
/// </summary>
public class FakePrometheusQueryClient : IPrometheusQueryClient
{
private Dictionary<string, double> results = new();
public void SetResults(Dictionary<string, double> queryResults)
{
results = queryResults;
}
public Task<double?> QueryScalarAsync(KubernetesCluster cluster, string prometheusUrl, string promql, CancellationToken ct = default)
{
if (results.TryGetValue(promql, out double value))
{
return Task.FromResult<double?>(value);
}
return Task.FromResult<double?>(null);
}
}
/// <summary>
/// Simple fake repository for test isolation.
/// </summary>
public class FakeClusterRepository : IClusterRepository
{
private readonly List<KubernetesCluster> clusters = new();
public Task<KubernetesCluster?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return Task.FromResult(clusters.FirstOrDefault(c => c.Id == id));
}
public Task<IReadOnlyList<KubernetesCluster>> GetAllAsync(CancellationToken ct = default)
{
return Task.FromResult<IReadOnlyList<KubernetesCluster>>(clusters.AsReadOnly());
}
public Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
clusters.Add(cluster);
return Task.CompletedTask;
}
public Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
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,183 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.ClusterSettings;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Cluster settings let operators view and edit configuration that lives on the
/// live cluster — like the list of allowed container registries in the Kyverno
/// restrict-image-registries policy. These tests verify the handler logic without
/// touching a real cluster. The actual Kubernetes interaction is behind an
/// IClusterSettingsReader abstraction so we can mock it in tests.
/// </summary>
public class ClusterSettingsTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IClusterSettingsReader> settingsReader;
private readonly Mock<IClusterSettingsWriter> settingsWriter;
private readonly GetClusterSettingsHandler getHandler;
private readonly UpdateClusterSettingsHandler updateHandler;
public ClusterSettingsTests()
{
repository = new InMemoryClusterRepository();
settingsReader = new Mock<IClusterSettingsReader>();
settingsWriter = new Mock<IClusterSettingsWriter>();
getHandler = new GetClusterSettingsHandler(repository, settingsReader.Object);
updateHandler = new UpdateClusterSettingsHandler(repository, settingsWriter.Object);
}
// ─── GET ───────────────────────────────────────────────────────────────
[Fact]
public async Task GetSettings_WithConnectedCluster_ReturnsSettings()
{
// Arrange — a connected cluster with some registries configured.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
List<string> registries = new() { "docker.io", "ghcr.io", "registry.k8s.io" };
settingsReader
.Setup(r => r.ReadAllowedRegistriesAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(registries);
// Act
Result<ClusterSettingsDto> result = await getHandler.HandleAsync(cluster.Id);
// Assert — the returned settings should include the registries from the live cluster.
result.IsSuccess.Should().BeTrue();
result.Value!.AllowedRegistries.Should().BeEquivalentTo(registries);
}
[Fact]
public async Task GetSettings_WithNonExistentCluster_ReturnsFailure()
{
// Act
Result<ClusterSettingsDto> result = await getHandler.HandleAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task GetSettings_WithDisconnectedCluster_ReturnsFailure()
{
// Arrange — a cluster that hasn't been connected yet.
KubernetesCluster cluster = KubernetesCluster.Register(
"pending", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act
Result<ClusterSettingsDto> result = await getHandler.HandleAsync(cluster.Id);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
// ─── UPDATE ────────────────────────────────────────────────────────────
[Fact]
public async Task UpdateSettings_WithValidRegistries_CallsWriter()
{
// Arrange — a connected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
List<string> newRegistries = new() { "docker.io", "ghcr.io", "myregistry.com" };
settingsWriter
.Setup(w => w.UpdateAllowedRegistriesAsync(cluster, newRegistries, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Success());
UpdateClusterSettingsRequest request = new(AllowedRegistries: newRegistries);
// Act
Result result = await updateHandler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
settingsWriter.Verify(
w => w.UpdateAllowedRegistriesAsync(cluster, newRegistries, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task UpdateSettings_WithEmptyRegistries_ReturnsFailure()
{
// Arrange
KubernetesCluster cluster = KubernetesCluster.Register(
"prod", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
UpdateClusterSettingsRequest request = new(AllowedRegistries: new List<string>());
// Act
Result result = await updateHandler.HandleAsync(cluster.Id, request);
// Assert — can't have zero allowed registries, that would block all pods.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("at least one");
}
[Fact]
public async Task UpdateSettings_WithNonExistentCluster_ReturnsFailure()
{
// Arrange
UpdateClusterSettingsRequest request = new(AllowedRegistries: new List<string> { "docker.io" });
// Act
Result result = await updateHandler.HandleAsync(Guid.NewGuid(), request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task UpdateSettings_WithDisconnectedCluster_ReturnsFailure()
{
// Arrange
KubernetesCluster cluster = KubernetesCluster.Register(
"pending", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
UpdateClusterSettingsRequest request = new(AllowedRegistries: new List<string> { "docker.io" });
// Act
Result result = await updateHandler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
}

View File

@@ -0,0 +1,137 @@
using EntKube.Clusters.Features.AdoptCluster.Components;
using FluentAssertions;
using k8s.Models;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the shared CNPG database provisioner that builds Job and Secret
/// manifests for creating databases on a CNPG cluster. These are pure manifest
/// builders — no Kubernetes API calls — so they're fully unit-testable.
/// </summary>
public class CnpgDatabaseProvisionerTests
{
[Fact]
public void GetPrimaryHost_ReturnsCorrectFqdn()
{
// The CNPG primary service follows the naming convention
// {clusterName}-rw.{namespace}.svc.cluster.local.
string host = CnpgDatabaseProvisioner.GetPrimaryHost("shared-pg", "cnpg-system");
host.Should().Be("shared-pg-rw.cnpg-system.svc.cluster.local");
}
[Fact]
public void BuildCreateDatabaseJob_TargetsPrimaryService()
{
// The Job should connect to the CNPG cluster's read-write primary
// service to execute the CREATE ROLE and CREATE DATABASE commands.
V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob(
"shared-pg", "cnpg-system", "gitea", "gitea", "secret123");
// The psql command should target {clusterName}-rw.
string args = job.Spec.Template.Spec.Containers[0].Args[0];
args.Should().Contain("psql -h shared-pg-rw");
}
[Fact]
public void BuildCreateDatabaseJob_UsesSuperuserSecret()
{
// The Job needs the superuser password from the CNPG cluster's
// automatically created {clusterName}-superuser secret.
V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob(
"shared-pg", "cnpg-system", "gitea", "gitea", "secret123");
V1EnvVar passwordEnv = job.Spec.Template.Spec.Containers[0].Env[0];
passwordEnv.Name.Should().Be("POSTGRES_PASSWORD");
passwordEnv.ValueFrom!.SecretKeyRef!.Name.Should().Be("shared-pg-superuser");
passwordEnv.ValueFrom.SecretKeyRef.Key.Should().Be("password");
}
[Fact]
public void BuildCreateDatabaseJob_SetsCorrectNamespace()
{
// The Job must run in the same namespace as the CNPG cluster
// so it can resolve the primary service by short name.
V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob(
"shared-pg", "cnpg-system", "harbor", "harbor", "pass");
job.Metadata.NamespaceProperty.Should().Be("cnpg-system");
}
[Fact]
public void BuildCreateDatabaseJob_HasManagementLabels()
{
// The Job should be labeled for EntKube management and discoverability.
V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob(
"shared-pg", "cnpg-system", "keycloak", "keycloak", "pass");
job.Metadata.Labels["app.kubernetes.io/managed-by"].Should().Be("entkube");
job.Metadata.Labels["entkube.io/operation"].Should().Be("create-database");
}
[Fact]
public void BuildCreateDatabaseJob_UsesIdempotentSql()
{
// The SQL script should use IF NOT EXISTS patterns so re-running
// the Job on an already-provisioned database doesn't fail.
V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob(
"shared-pg", "cnpg-system", "gitea", "gitea", "secret123");
string args = job.Spec.Template.Spec.Containers[0].Args[0];
args.Should().Contain("WHERE NOT EXISTS (SELECT FROM pg_roles");
args.Should().Contain("WHERE NOT EXISTS (SELECT FROM pg_database");
}
[Fact]
public void BuildCredentialsSecret_ContainsAllConnectionDetails()
{
// The credentials secret should contain host, database, username,
// and password so the consuming service can connect.
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
"gitea-db-credentials", "gitea",
"shared-pg-rw.cnpg-system.svc.cluster.local",
"gitea", "gitea", "secret123");
secret.StringData["host"].Should().Be("shared-pg-rw.cnpg-system.svc.cluster.local");
secret.StringData["database"].Should().Be("gitea");
secret.StringData["username"].Should().Be("gitea");
secret.StringData["password"].Should().Be("secret123");
}
[Fact]
public void BuildCredentialsSecret_HasManagementLabels()
{
// The secret should be labeled for EntKube management.
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
"harbor-db-credentials", "harbor",
"shared-pg-rw.cnpg-system.svc.cluster.local",
"harbor", "harbor", "pass");
secret.Metadata.Labels["app.kubernetes.io/managed-by"].Should().Be("entkube");
secret.Metadata.Labels["entkube.io/purpose"].Should().Be("database-credentials");
}
[Fact]
public void BuildCredentialsSecret_SetsCorrectNamespace()
{
// The secret must be created in the target service's namespace,
// not the CNPG cluster's namespace.
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
"keycloak-db-credentials", "keycloak",
"shared-pg-rw.cnpg-system.svc.cluster.local",
"keycloak", "keycloak", "pass");
secret.Metadata.NamespaceProperty.Should().Be("keycloak");
}
}

View File

@@ -0,0 +1,237 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
using EntKube.Clusters.Features.AdoptCluster.DeployComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Verifies that adoption scans, deployments, and configuration changes
/// persist component state on the cluster aggregate. This ensures we don't
/// need to re-scan the cluster every time the UI loads — detected components
/// are stored and updated incrementally.
/// </summary>
public class ComponentPersistenceTests
{
private readonly InMemoryClusterRepository repository;
public ComponentPersistenceTests()
{
repository = new InMemoryClusterRepository();
}
[Fact]
public async Task AdoptCluster_PersistsDetectedComponentsOnCluster()
{
// Arrange — A connected cluster with two components: one installed, one missing.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
Mock<IAdoptionCheck> kyvernoCheck = new();
kyvernoCheck.Setup(c => c.ComponentName).Returns("kyverno");
kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running" }, new List<string>(),
new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno",
new Dictionary<string, string> { ["replicas"] = "3" })));
Mock<IAdoptionCheck> certManagerCheck = new();
certManagerCheck.Setup(c => c.ComponentName).Returns("cert-manager");
certManagerCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("cert-manager", ComponentStatus.NotInstalled,
new List<string>(), new List<string> { "No pods found" }));
List<IAdoptionCheck> checks = new() { kyvernoCheck.Object, certManagerCheck.Object };
AdoptClusterHandler handler = new(repository, checks, NullLogger<AdoptClusterHandler>.Instance);
// Act — Run the adoption scan.
Result<AdoptionReport> result = await handler.HandleAsync(cluster.Id);
// Assert — Components should now be persisted on the cluster.
result.IsSuccess.Should().BeTrue();
KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id);
persisted!.Components.Should().HaveCount(2);
ClusterComponent kyverno = persisted.Components.First(c => c.ComponentName == "kyverno");
kyverno.Status.Should().Be(ComponentStatus.Installed);
kyverno.Version.Should().Be("3.3.4");
kyverno.Configuration["replicas"].Should().Be("3");
ClusterComponent certManager = persisted.Components.First(c => c.ComponentName == "cert-manager");
certManager.Status.Should().Be(ComponentStatus.NotInstalled);
}
[Fact]
public async Task ConfigureComponent_UpdatesPersistedConfiguration()
{
// Arrange — A cluster with monitoring already detected and persisted.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
cluster.UpdateComponents(new List<ComponentCheckResult>
{
new("monitoring", ComponentStatus.Installed, new List<string>(), new List<string>(),
new DiscoveredConfiguration("45.0.0", "monitoring", "kube-prometheus-stack",
new Dictionary<string, string> { ["retention"] = "30d" }))
});
await repository.AddAsync(cluster);
Mock<IComponentInstaller> monitoringInstaller = new();
monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring");
monitoringInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "monitoring", "Reconfigured", new List<string> { "retention=90d" }));
List<IComponentInstaller> installers = new() { monitoringInstaller.Object };
ConfigureComponentHandler handler = new(repository, installers);
ConfigureComponentRequest request = new("monitoring", Values: new Dictionary<string, string>
{
["retention"] = "90d"
});
// Act — Apply the configuration change.
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — The persisted component configuration should reflect the new value.
result.IsSuccess.Should().BeTrue();
KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id);
ClusterComponent monitoring = persisted!.Components.First(c => c.ComponentName == "monitoring");
monitoring.Configuration["retention"].Should().Be("90d");
}
[Fact]
public async Task DeployComponent_UpdatesPersistedComponentStatus()
{
// Arrange — A cluster with cert-manager detected as NotInstalled.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
cluster.UpdateComponents(new List<ComponentCheckResult>
{
new("cert-manager", ComponentStatus.NotInstalled, new List<string>(), new List<string> { "Missing" })
});
await repository.AddAsync(cluster);
Mock<IComponentInstaller> certManagerInstaller = new();
certManagerInstaller.Setup(i => i.ComponentName).Returns("cert-manager");
certManagerInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cert-manager", "Installed v1.14.0",
new List<string> { "Helm install cert-manager v1.14.0" }));
List<IComponentInstaller> installers = new() { certManagerInstaller.Object };
DeployComponentHandler handler = new(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("cert-manager", Version: "1.14.0", Namespace: "cert-manager");
// Act — Deploy the component.
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — The persisted component should now show Installed.
result.IsSuccess.Should().BeTrue();
KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id);
ClusterComponent certManager = persisted!.Components.First(c => c.ComponentName == "cert-manager");
certManager.Status.Should().Be(ComponentStatus.Installed);
}
[Fact]
public async Task ConfigureComponent_WhenInstallerFails_DoesNotUpdatePersisted()
{
// Arrange — Configuration that fails should NOT update the persisted state.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
cluster.UpdateComponents(new List<ComponentCheckResult>
{
new("monitoring", ComponentStatus.Installed, new List<string>(), new List<string>(),
new DiscoveredConfiguration("45.0.0", "monitoring", "kube-prometheus-stack",
new Dictionary<string, string> { ["retention"] = "30d" }))
});
await repository.AddAsync(cluster);
Mock<IComponentInstaller> monitoringInstaller = new();
monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring");
monitoringInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(false, "monitoring", "Helm failed", new List<string>()));
List<IComponentInstaller> installers = new() { monitoringInstaller.Object };
ConfigureComponentHandler handler = new(repository, installers);
ConfigureComponentRequest request = new("monitoring", Values: new Dictionary<string, string>
{
["retention"] = "90d"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Original value preserved.
result.IsFailure.Should().BeTrue();
KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id);
ClusterComponent monitoring = persisted!.Components.First(c => c.ComponentName == "monitoring");
monitoring.Configuration["retention"].Should().Be("30d");
}
[Fact]
public async Task DeployComponent_WhenInstallerFails_DoesNotUpdatePersisted()
{
// Arrange — Failed deployment should not change persisted status.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
cluster.UpdateComponents(new List<ComponentCheckResult>
{
new("cert-manager", ComponentStatus.NotInstalled, new List<string>(), new List<string>())
});
await repository.AddAsync(cluster);
Mock<IComponentInstaller> installer = new();
installer.Setup(i => i.ComponentName).Returns("cert-manager");
installer
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(false, "cert-manager", "Timeout", new List<string>()));
List<IComponentInstaller> installers = new() { installer.Object };
DeployComponentHandler handler = new(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("cert-manager");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id);
ClusterComponent certManager = persisted!.Components.First(c => c.ComponentName == "cert-manager");
certManager.Status.Should().Be(ComponentStatus.NotInstalled);
}
}

View File

@@ -0,0 +1,634 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the component configuration schema system. Schemas define what
/// parameters each component accepts, their types, validation constraints,
/// and how they're grouped in the UI. This enables the frontend to render
/// proper typed forms instead of raw key/value editors.
/// </summary>
public class ConfigurationSchemaTests
{
[Fact]
public void ConfigurationParameter_WithRequiredString_ValidatesPresence()
{
// Arrange — A required parameter with no value provided.
ConfigurationParameter param = new(
Key: "email",
DisplayName: "Email Address",
Description: "The email registered with the certificate authority",
Type: ParameterType.String,
Group: "General",
IsRequired: true);
// Act
ParameterValidationResult result = param.Validate(null);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("required");
}
[Fact]
public void ConfigurationParameter_WithRequiredString_PassesWhenProvided()
{
// Arrange
ConfigurationParameter param = new(
Key: "email",
DisplayName: "Email Address",
Description: "The email registered with the certificate authority",
Type: ParameterType.String,
Group: "General",
IsRequired: true);
// Act
ParameterValidationResult result = param.Validate("admin@example.com");
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_WithIntegerType_RejectsNonNumeric()
{
// Arrange — An integer parameter with a non-numeric value.
ConfigurationParameter param = new(
Key: "replicas",
DisplayName: "Replicas",
Description: "Number of pod replicas",
Type: ParameterType.Integer,
Group: "Scaling",
IsRequired: true,
Min: 1,
Max: 10);
// Act
ParameterValidationResult result = param.Validate("not-a-number");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("integer");
}
[Fact]
public void ConfigurationParameter_WithIntegerType_RejectsOutOfRange()
{
// Arrange — An integer outside the allowed range.
ConfigurationParameter param = new(
Key: "replicas",
DisplayName: "Replicas",
Description: "Number of pod replicas",
Type: ParameterType.Integer,
Group: "Scaling",
IsRequired: true,
Min: 1,
Max: 10);
// Act
ParameterValidationResult result = param.Validate("15");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("10");
}
[Fact]
public void ConfigurationParameter_WithIntegerType_AcceptsValidValue()
{
// Arrange
ConfigurationParameter param = new(
Key: "replicas",
DisplayName: "Replicas",
Description: "Number of pod replicas",
Type: ParameterType.Integer,
Group: "Scaling",
IsRequired: true,
Min: 1,
Max: 10);
// Act
ParameterValidationResult result = param.Validate("3");
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_WithBooleanType_RejectsInvalidValues()
{
// Arrange — A boolean parameter with a non-boolean value.
ConfigurationParameter param = new(
Key: "grafanaEnabled",
DisplayName: "Enable Grafana",
Description: "Whether to deploy Grafana alongside Prometheus",
Type: ParameterType.Boolean,
Group: "Features");
// Act
ParameterValidationResult result = param.Validate("maybe");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("true");
}
[Fact]
public void ConfigurationParameter_WithBooleanType_AcceptsTrueAndFalse()
{
// Arrange
ConfigurationParameter param = new(
Key: "grafanaEnabled",
DisplayName: "Enable Grafana",
Description: "Whether to deploy Grafana alongside Prometheus",
Type: ParameterType.Boolean,
Group: "Features");
// Act & Assert
param.Validate("true").IsValid.Should().BeTrue();
param.Validate("false").IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_WithSelectType_RejectsUnknownOption()
{
// Arrange — A select parameter with allowed values.
ConfigurationParameter param = new(
Key: "provider",
DisplayName: "Ingress Provider",
Description: "Which ingress controller to use",
Type: ParameterType.Select,
Group: "General",
IsRequired: true,
AllowedValues: new List<string> { "traefik", "istio" });
// Act
ParameterValidationResult result = param.Validate("nginx");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("traefik");
}
[Fact]
public void ConfigurationParameter_WithSelectType_AcceptsAllowedValue()
{
// Arrange
ConfigurationParameter param = new(
Key: "provider",
DisplayName: "Ingress Provider",
Description: "Which ingress controller to use",
Type: ParameterType.Select,
Group: "General",
IsRequired: true,
AllowedValues: new List<string> { "traefik", "istio" });
// Act
ParameterValidationResult result = param.Validate("istio");
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_Optional_AcceptsNullOrEmpty()
{
// Arrange — An optional parameter with no value is valid.
ConfigurationParameter param = new(
Key: "storageSize",
DisplayName: "Storage Size",
Description: "PVC storage size",
Type: ParameterType.String,
Group: "Storage",
IsRequired: false,
DefaultValue: "50Gi");
// Act & Assert
param.Validate(null).IsValid.Should().BeTrue();
param.Validate("").IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationSchema_ValidateAll_ReturnsAllErrors()
{
// Arrange — A schema with multiple parameters, some invalid.
ConfigurationSchema schema = new(
ComponentName: "monitoring",
DisplayName: "Monitoring (kube-prometheus-stack)",
Description: "Prometheus, Grafana, and Alertmanager stack",
Parameters: new List<ConfigurationParameter>
{
new(Key: "retention", DisplayName: "Retention", Description: "Data retention period",
Type: ParameterType.String, Group: "Storage", IsRequired: true),
new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus replicas",
Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10),
new(Key: "grafanaEnabled", DisplayName: "Enable Grafana", Description: "Deploy Grafana",
Type: ParameterType.Boolean, Group: "Features")
});
Dictionary<string, string> values = new()
{
["replicas"] = "999",
["grafanaEnabled"] = "invalid"
};
// Act
SchemaValidationResult result = schema.Validate(values);
// Assert — Three errors: retention missing, replicas out of range, grafana invalid.
result.IsValid.Should().BeFalse();
result.Errors.Should().HaveCount(3);
result.Errors.Should().ContainKey("retention");
result.Errors.Should().ContainKey("replicas");
result.Errors.Should().ContainKey("grafanaEnabled");
}
[Fact]
public void ConfigurationSchema_ValidateAll_PassesWithValidValues()
{
// Arrange
ConfigurationSchema schema = new(
ComponentName: "monitoring",
DisplayName: "Monitoring (kube-prometheus-stack)",
Description: "Prometheus, Grafana, and Alertmanager stack",
Parameters: new List<ConfigurationParameter>
{
new(Key: "retention", DisplayName: "Retention", Description: "Data retention period",
Type: ParameterType.String, Group: "Storage", IsRequired: true),
new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus replicas",
Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10)
});
Dictionary<string, string> values = new()
{
["retention"] = "30d",
["replicas"] = "2"
};
// Act
SchemaValidationResult result = schema.Validate(values);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
public void ConfigurationSchema_GroupsParameters_ByGroupName()
{
// Arrange — Schema parameters can be grouped for UI rendering.
ConfigurationSchema schema = new(
ComponentName: "monitoring",
DisplayName: "Monitoring",
Description: "Prometheus stack",
Parameters: new List<ConfigurationParameter>
{
new(Key: "retention", DisplayName: "Retention", Description: "Retention",
Type: ParameterType.String, Group: "Storage"),
new(Key: "storageSize", DisplayName: "Storage Size", Description: "PVC size",
Type: ParameterType.String, Group: "Storage"),
new(Key: "replicas", DisplayName: "Replicas", Description: "Replicas",
Type: ParameterType.Integer, Group: "Scaling", Min: 1, Max: 10),
new(Key: "grafanaEnabled", DisplayName: "Grafana", Description: "Enable Grafana",
Type: ParameterType.Boolean, Group: "Features")
});
// Act
Dictionary<string, List<ConfigurationParameter>> groups = schema.GetParametersByGroup();
// Assert
groups.Should().HaveCount(3);
groups["Storage"].Should().HaveCount(2);
groups["Scaling"].Should().HaveCount(1);
groups["Features"].Should().HaveCount(1);
}
[Fact]
public void CertManagerSchema_DoesNotContainLetsEncryptParameters()
{
// Arrange & Act — The cert-manager schema should only have scaling
// parameters. Let's Encrypt configuration belongs to the letsencrypt component.
ConfigurationSchema schema = ComponentSchemas.GetSchema("cert-manager");
// Assert — No Let's Encrypt-related keys should exist.
List<string> paramKeys = schema.Parameters.Select(p => p.Key).ToList();
paramKeys.Should().NotContain("letsEncryptEmail");
paramKeys.Should().NotContain("httpSolverEnabled");
paramKeys.Should().NotContain("dnsSolverEnabled");
paramKeys.Should().NotContain("dnsSolverProvider");
paramKeys.Should().Contain("replicas");
}
[Fact]
public void LetsEncryptSchema_ContainsSolverAndEmailParameters()
{
// Arrange & Act — The letsencrypt schema should own all the ACME/solver
// configuration that was previously in cert-manager.
ConfigurationSchema schema = ComponentSchemas.GetSchema("letsencrypt");
// Assert
List<string> paramKeys = schema.Parameters.Select(p => p.Key).ToList();
paramKeys.Should().Contain("email");
paramKeys.Should().Contain("httpSolverEnabled");
paramKeys.Should().Contain("dnsSolverEnabled");
paramKeys.Should().Contain("dnsSolverProvider");
schema.Parameters.First(p => p.Key == "email").IsRequired.Should().BeTrue();
}
[Fact]
public void CertManagerSchema_OnlyHasScalingGroup()
{
// The cert-manager component is purely the runtime engine — its schema
// should only expose scaling settings, not TLS/issuer configuration.
ConfigurationSchema schema = ComponentSchemas.GetSchema("cert-manager");
Dictionary<string, List<ConfigurationParameter>> groups = schema.GetParametersByGroup();
groups.Should().HaveCount(1);
groups.Keys.Should().Contain("Scaling");
}
// ─── Contextual Schema Enrichment ─────────────────────────────────────
[Fact]
public void EnrichForCluster_WithIstioIngress_DefaultsToGatewayHTTPRoute()
{
// Arrange — A cluster where the ingress component reports Istio as the
// provider, with an internal gateway detected. When configuring Let's
// Encrypt on this cluster, the HTTP-01 solver should default to
// gatewayHTTPRoute mode with the internal gateway pre-selected.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasInternalGateway"] = "True",
["hasExternalGateway"] = "True"
})
};
// Act — Enrich the letsencrypt schema using the cluster's component state.
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — The HTTP-01 solver should default to Gateway API mode.
ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode");
modeParam.DefaultValue.Should().Be("gatewayHTTPRoute");
ConfigurationParameter gatewayNameParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayName");
gatewayNameParam.DefaultValue.Should().Be("internal");
ConfigurationParameter gatewayNsParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayNamespace");
gatewayNsParam.DefaultValue.Should().Be("internal-ingress");
}
[Fact]
public void EnrichForCluster_WithTraefikIngress_DefaultsToIngressMode()
{
// Arrange — A cluster using Traefik for ingress. The HTTP-01 solver
// should default to ingress mode with "traefik" as the class.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "traefik",
["ingressClassRegistered"] = "True"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — Should remain in ingress mode with traefik class.
ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode");
modeParam.DefaultValue.Should().Be("ingress");
ConfigurationParameter classParam = enriched.Parameters.First(p => p.Key == "httpSolverIngressClass");
classParam.DefaultValue.Should().Be("traefik");
}
[Fact]
public void EnrichForCluster_WithNoIngressComponent_UsesStaticDefaults()
{
// Arrange — No ingress component detected on the cluster. The schema
// should return unchanged static defaults.
List<ClusterComponent> components = new();
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — Static defaults should be preserved.
ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode");
modeParam.DefaultValue.Should().Be("ingress");
}
[Fact]
public void EnrichForCluster_WithIstioAndDiscoveredGateways_SetsGatewayAsSelect()
{
// Arrange — A cluster with Istio and discovered gateways stored in
// the ingress component's config. The httpSolverGatewayName should
// become a Select parameter with the available gateways as options.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasInternalGateway"] = "True",
["hasExternalGateway"] = "True"
}),
BuildComponent("letsencrypt", ComponentStatus.Installed, new Dictionary<string, string>
{
["availableGateways"] = "[{\"name\":\"internal\",\"namespace\":\"internal-ingress\"},{\"name\":\"external\",\"namespace\":\"external-ingress\"}]"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — Gateway name should be a Select with discovered gateways as options.
ConfigurationParameter gatewayParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayName");
gatewayParam.Type.Should().Be(ParameterType.Select);
gatewayParam.AllowedValues.Should().Contain("internal");
gatewayParam.AllowedValues.Should().Contain("external");
}
[Fact]
public void EnrichForCluster_NonLetsEncryptComponent_ReturnsUnchanged()
{
// Arrange — Enriching a component other than letsencrypt should
// return the static schema unchanged.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("cert-manager", components);
// Assert — cert-manager schema should be unchanged.
ConfigurationSchema original = ComponentSchemas.GetSchema("cert-manager");
enriched.Parameters.Should().HaveCount(original.Parameters.Count);
}
[Fact]
public void EnrichForCluster_WithIstioAndExistingLetsEncryptConfig_PreservesDiscoveredValues()
{
// Arrange — A cluster where Let's Encrypt was already configured with
// specific values. The enrichment should set defaults from the ingress
// provider but the existing discovered config (email, solver type) is
// separate and handled by the UI pre-fill, not schema defaults.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasInternalGateway"] = "True",
["hasExternalGateway"] = "False"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — With only internal gateway, external should not appear in options.
// The gateway namespace should default to internal-ingress.
ConfigurationParameter gatewayNsParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayNamespace");
gatewayNsParam.DefaultValue.Should().Be("internal-ingress");
}
[Fact]
public void EnrichForCluster_IngressWithExternalGateway_DefaultsEnableExternalToTrue()
{
// Arrange — The cluster scan detected an external gateway. When the
// user opens the ingress configuration, the enable_external toggle
// should default to true so it reflects reality.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasExternalGateway"] = "True"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("ingress", components);
// Assert — enable_external should default to "true".
ConfigurationParameter extParam = enriched.Parameters.First(p => p.Key == "enable_external");
extParam.DefaultValue.Should().Be("true");
}
[Fact]
public void EnrichForCluster_IngressWithoutExternalGateway_DefaultsEnableExternalToFalse()
{
// Arrange — No external gateway detected. The default should stay false.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasExternalGateway"] = "False"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("ingress", components);
// Assert — enable_external should remain "false".
ConfigurationParameter extParam = enriched.Parameters.First(p => p.Key == "enable_external");
extParam.DefaultValue.Should().Be("false");
}
// ─── Helpers ──────────────────────────────────────────────────────────
private static ClusterComponent BuildComponent(
string name, ComponentStatus status, Dictionary<string, string> config)
{
ComponentCheckResult checkResult = new(
name, status, new List<string>(), new List<string>(),
new DiscoveredConfiguration(null, null, null, config));
return ClusterComponent.FromCheckResult(checkResult);
}
[Fact]
public void ParameterType_IncludesPostgresCluster()
{
// The UI needs a PostgresCluster parameter type to render a CNPG cluster
// picker, similar to how StorageBucket renders a bucket picker.
ParameterType type = ParameterType.PostgresCluster;
type.Should().BeDefined();
}
}

View File

@@ -0,0 +1,197 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
public class ConfigureComponentHandlerTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> monitoringInstaller;
private readonly Mock<IComponentInstaller> kyvernoInstaller;
private readonly ConfigureComponentHandler handler;
public ConfigureComponentHandlerTests()
{
repository = new InMemoryClusterRepository();
monitoringInstaller = new Mock<IComponentInstaller>();
monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring");
kyvernoInstaller = new Mock<IComponentInstaller>();
kyvernoInstaller.Setup(i => i.ComponentName).Returns("kyverno");
List<IComponentInstaller> installers = new() { monitoringInstaller.Object, kyvernoInstaller.Object };
handler = new ConfigureComponentHandler(repository, installers);
}
[Fact]
public async Task HandleAsync_WithValidComponent_CallsConfigureAsync()
{
// Arrange — A connected cluster and a request to change Prometheus retention.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
monitoringInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "monitoring", "Reconfigured", new List<string> { "retention=90d" }));
ConfigureComponentRequest request = new("monitoring", Values: new Dictionary<string, string>
{
["retention"] = "90d"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.Success.Should().BeTrue();
result.Value.Message.Should().Be("Reconfigured");
monitoringInstaller.Verify(
i => i.ConfigureAsync(cluster, It.Is<ComponentConfiguration>(c => c.Values["retention"] == "90d"), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Arrange
ConfigureComponentRequest request = new("monitoring", Values: new Dictionary<string, string>
{
["retention"] = "90d"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(Guid.NewGuid(), request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_WithDisconnectedCluster_ReturnsFailure()
{
// Arrange — Cluster that isn't connected can't be configured.
KubernetesCluster cluster = KubernetesCluster.Register(
"offline-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
ConfigureComponentRequest request = new("monitoring", Values: new Dictionary<string, string>
{
["retention"] = "90d"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_WithUnknownComponent_ReturnsFailure()
{
// Arrange — Request an installer that doesn't exist.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ConfigureComponentRequest request = new("nonexistent-thing", Values: new Dictionary<string, string>
{
["foo"] = "bar"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("No installer found");
result.Error.Should().Contain("monitoring");
result.Error.Should().Contain("kyverno");
}
[Fact]
public async Task HandleAsync_WhenConfigureFails_ReturnsFailure()
{
// Arrange — The installer reports a failure.
KubernetesCluster cluster = KubernetesCluster.Register(
"broken-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(false, "kyverno", "Helm upgrade failed: timeout", new List<string> { "Error: timeout" }));
ConfigureComponentRequest request = new("kyverno", Values: new Dictionary<string, string>
{
["admissionReplicas"] = "4"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Helm upgrade failed");
}
[Fact]
public async Task HandleAsync_PassesNamespaceToConfiguration()
{
// Arrange — Verify the namespace from the request reaches the installer.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
monitoringInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "monitoring", "Done", new List<string>()));
ConfigureComponentRequest request = new("monitoring", Namespace: "custom-monitoring", Values: new Dictionary<string, string>
{
["retention"] = "7d"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
monitoringInstaller.Verify(
i => i.ConfigureAsync(cluster, It.Is<ComponentConfiguration>(c => c.Namespace == "custom-monitoring"), It.IsAny<CancellationToken>()),
Times.Once);
}
}

View File

@@ -0,0 +1,448 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests the configure handler with the new component installers.
/// Verifies that reconfiguration requests route correctly and pass
/// the right configuration values to each installer.
/// </summary>
public class ConfigureNewComponentsTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> cnpgInstaller;
private readonly Mock<IComponentInstaller> redisInstaller;
private readonly Mock<IComponentInstaller> certManagerInstaller;
private readonly Mock<IComponentInstaller> externalSecretsInstaller;
private readonly Mock<IComponentInstaller> rabbitmqInstaller;
private readonly Mock<IComponentInstaller> customDnsInstaller;
private readonly ConfigureComponentHandler handler;
public ConfigureNewComponentsTests()
{
repository = new InMemoryClusterRepository();
cnpgInstaller = new Mock<IComponentInstaller>();
cnpgInstaller.Setup(i => i.ComponentName).Returns("cnpg");
redisInstaller = new Mock<IComponentInstaller>();
redisInstaller.Setup(i => i.ComponentName).Returns("redis");
certManagerInstaller = new Mock<IComponentInstaller>();
certManagerInstaller.Setup(i => i.ComponentName).Returns("cert-manager");
externalSecretsInstaller = new Mock<IComponentInstaller>();
externalSecretsInstaller.Setup(i => i.ComponentName).Returns("external-secrets");
rabbitmqInstaller = new Mock<IComponentInstaller>();
rabbitmqInstaller.Setup(i => i.ComponentName).Returns("rabbitmq");
customDnsInstaller = new Mock<IComponentInstaller>();
customDnsInstaller.Setup(i => i.ComponentName).Returns("custom-dns");
List<IComponentInstaller> installers = new()
{
cnpgInstaller.Object,
redisInstaller.Object,
certManagerInstaller.Object,
externalSecretsInstaller.Object,
rabbitmqInstaller.Object,
customDnsInstaller.Object
};
handler = new ConfigureComponentHandler(repository, installers);
}
[Fact]
public async Task HandleAsync_ConfigureCnpg_PassesMonitoringFlag()
{
// Arrange — Reconfigure CNPG to enable Prometheus monitoring.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
cnpgInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cnpg", "CNPG reconfigured", new List<string> { "monitoring=true" }));
ConfigureComponentRequest request = new("cnpg", Values: new Dictionary<string, string>
{
["monitoringEnabled"] = "true",
["operatorReplicas"] = "2"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
cnpgInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["monitoringEnabled"] == "true" &&
c.Values["operatorReplicas"] == "2"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureRedis_PassesSentinelAndPersistence()
{
// Arrange — Reconfigure Redis: scale replicas and change persistence size.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
redisInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "redis", "Redis reconfigured", new List<string> { "replicas=5" }));
ConfigureComponentRequest request = new("redis", Values: new Dictionary<string, string>
{
["replicaCount"] = "5",
["persistenceSize"] = "16Gi",
["maxMemory"] = "512mb"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
redisInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["replicaCount"] == "5" &&
c.Values["persistenceSize"] == "16Gi" &&
c.Values["maxMemory"] == "512mb"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureCertManager_PassesDnsSolverConfig()
{
// Arrange — Switch cert-manager to use Route53 DNS01 solver.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
certManagerInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cert-manager", "cert-manager reconfigured",
new List<string> { "Updated issuers with Route53 DNS01" }));
ConfigureComponentRequest request = new("cert-manager", Values: new Dictionary<string, string>
{
["letsEncryptEmail"] = "ops@company.com",
["dnsSolverEnabled"] = "true",
["dnsSolverProvider"] = "route53",
["dnsSolverHostedZone"] = "Z1234567890"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
certManagerInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["dnsSolverProvider"] == "route53" &&
c.Values["dnsSolverHostedZone"] == "Z1234567890"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureExternalSecrets_PassesStoreProvider()
{
// Arrange — Add an AWS Secrets Manager ClusterSecretStore.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
externalSecretsInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "external-secrets", "ESO reconfigured",
new List<string> { "ClusterSecretStore for AWS created" }));
ConfigureComponentRequest request = new("external-secrets", Values: new Dictionary<string, string>
{
["storeProvider"] = "aws",
["storeRegion"] = "eu-north-1",
["storeSecretName"] = "aws-credentials"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
externalSecretsInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["storeProvider"] == "aws" &&
c.Values["storeRegion"] == "eu-north-1"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureRabbitMQ_PassesTopologyConfig()
{
// Arrange — Enable topology operator on RabbitMQ.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
rabbitmqInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "rabbitmq", "RabbitMQ reconfigured",
new List<string> { "Topology operator enabled" }));
ConfigureComponentRequest request = new("rabbitmq", Values: new Dictionary<string, string>
{
["topologyOperator"] = "true",
["operatorReplicas"] = "2"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
rabbitmqInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["topologyOperator"] == "true" &&
c.Values["operatorReplicas"] == "2"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureCertManager_CaseInsensitiveName()
{
// Arrange — Component name matching should be case-insensitive.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
certManagerInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cert-manager", "Reconfigured", new List<string>()));
ConfigureComponentRequest request = new("Cert-Manager", Values: new Dictionary<string, string>
{
["replicas"] = "2"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
}
[Fact]
public async Task HandleAsync_ConfigureWithNamespace_PassesNamespaceThrough()
{
// Arrange — Specify a custom namespace for Redis reconfiguration.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
redisInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "redis", "Reconfigured", new List<string>()));
ConfigureComponentRequest request = new("redis", Namespace: "custom-redis-ns", Values: new Dictionary<string, string>
{
["replicaCount"] = "5"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
redisInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c => c.Namespace == "custom-redis-ns"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureCustomDns_PassesStubZonesAndCorefile()
{
// Arrange — Reconfigure CoreDNS with new stub zones and custom Corefile entries.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
customDnsInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "custom-dns", "CoreDNS reconfigured",
new List<string> { "Stub zones updated", "Custom Corefile entries applied" }));
ConfigureComponentRequest request = new("custom-dns", Values: new Dictionary<string, string>
{
["stubZones"] = "corp.internal=10.0.0.53;svc.internal=10.0.1.53",
["customCorefileEntries"] = "log\nrewrite name api.old.local api.new.local",
["customRecords"] = "myservice.local=10.10.10.10"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
customDnsInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["stubZones"] == "corp.internal=10.0.0.53;svc.internal=10.0.1.53" &&
c.Values["customCorefileEntries"] == "log\nrewrite name api.old.local api.new.local" &&
c.Values["customRecords"] == "myservice.local=10.10.10.10"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_WithSchema_RejectsInvalidIntegerValue()
{
// Arrange — CNPG has a schema requiring operatorReplicas as Integer with min/max.
// Providing a non-numeric value should fail validation before reaching the installer.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ConfigurationSchema cnpgSchema = ComponentSchemas.GetSchema("cnpg");
cnpgInstaller.Setup(i => i.GetSchema()).Returns(cnpgSchema);
ConfigureComponentRequest request = new("cnpg", Values: new Dictionary<string, string>
{
["operatorReplicas"] = "not-a-number"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — should fail validation without ever calling ConfigureAsync.
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("must be an integer");
cnpgInstaller.Verify(
i => i.ConfigureAsync(It.IsAny<KubernetesCluster>(), It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HandleAsync_WithSchema_RejectsValueOutOfRange()
{
// Arrange — Redis schema has maxReplicas=10 for replicas field.
// Providing a value above max should fail.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ConfigurationSchema redisSchema = ComponentSchemas.GetSchema("redis");
redisInstaller.Setup(i => i.GetSchema()).Returns(redisSchema);
ConfigureComponentRequest request = new("redis", Values: new Dictionary<string, string>
{
["replicaCount"] = "99"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("must be at most 10");
redisInstaller.Verify(
i => i.ConfigureAsync(It.IsAny<KubernetesCluster>(), It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HandleAsync_WithSchema_PassesValidConfiguration()
{
// Arrange — Provide valid values matching the CNPG schema.
// Validation should pass and ConfigureAsync should be called.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ConfigurationSchema cnpgSchema = ComponentSchemas.GetSchema("cnpg");
cnpgInstaller.Setup(i => i.GetSchema()).Returns(cnpgSchema);
cnpgInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cnpg", "Configured", new List<string>()));
ConfigureComponentRequest request = new("cnpg", Values: new Dictionary<string, string>
{
["operatorReplicas"] = "2",
["monitoringEnabled"] = "true"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — validation passes, installer is called.
result.IsSuccess.Should().BeTrue();
cnpgInstaller.Verify(
i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()),
Times.Once);
}
}

View File

@@ -0,0 +1,62 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.DeleteCluster;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class DeleteClusterHandlerTests
{
private readonly DeleteClusterHandler handler;
private readonly InMemoryClusterRepository repository;
public DeleteClusterHandlerTests()
{
repository = new InMemoryClusterRepository();
handler = new DeleteClusterHandler(repository);
}
[Fact]
public async Task HandleAsync_WithExistingCluster_DeletesAndReturnsSuccess()
{
// Arrange — Register a cluster so we have something to delete.
KubernetesCluster cluster = KubernetesCluster.Register(
"staging",
"https://staging-k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"staging-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act — Delete it by ID.
Result result = await handler.HandleAsync(cluster.Id);
// Assert — The deletion succeeds and the cluster is no longer in the repository.
result.IsSuccess.Should().BeTrue();
KubernetesCluster? deleted = await repository.GetByIdAsync(cluster.Id);
deleted.Should().BeNull();
}
[Fact]
public async Task HandleAsync_WithNonExistentId_ReturnsFailure()
{
// Arrange — An ID that doesn't exist.
Guid unknownId = Guid.NewGuid();
// Act
Result result = await handler.HandleAsync(unknownId);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,181 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.DeployComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
public class DeployComponentHandlerTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> kyvernoInstaller;
private readonly Mock<IComponentInstaller> policiesInstaller;
private readonly DeployComponentHandler handler;
public DeployComponentHandlerTests()
{
repository = new InMemoryClusterRepository();
kyvernoInstaller = new Mock<IComponentInstaller>();
policiesInstaller = new Mock<IComponentInstaller>();
kyvernoInstaller.Setup(i => i.ComponentName).Returns("kyverno");
policiesInstaller.Setup(i => i.ComponentName).Returns("security-policies");
List<IComponentInstaller> installers = new() { kyvernoInstaller.Object, policiesInstaller.Object };
handler = new DeployComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
}
[Fact]
public async Task HandleAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange
DeployComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(Guid.NewGuid(), request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_ClusterNotConnected_ReturnsFailure()
{
// Arrange — Cluster is Pending, can't deploy to it.
KubernetesCluster cluster = KubernetesCluster.Register(
"pending-cluster", "https://k8s.dev:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
DeployComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_UnknownComponent_ReturnsFailureWithAvailable()
{
// Arrange — Requesting a component that has no installer.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
DeployComponentRequest request = new("nonexistent-thing");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Should list available installers.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("kyverno");
result.Error.Should().Contain("security-policies");
}
[Fact]
public async Task HandleAsync_ValidComponent_DelegatesToInstaller()
{
// Arrange — Deploy Kyverno on a connected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "kyverno",
Message: "Kyverno 3.3.4 installed successfully",
Actions: new List<string> { "Helm install/upgrade kyverno v3.3.4" }));
DeployComponentRequest request = new("kyverno", Version: "3.3.4");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Should succeed and return installer output.
result.IsSuccess.Should().BeTrue();
result.Value!.Success.Should().BeTrue();
result.Value.ComponentName.Should().Be("kyverno");
result.Value.Actions.Should().Contain("Helm install/upgrade kyverno v3.3.4");
}
[Fact]
public async Task HandleAsync_InstallerFails_ReturnsFailure()
{
// Arrange — The installer encounters an error (e.g., helm timeout).
KubernetesCluster cluster = KubernetesCluster.Register(
"flaky-cluster", "https://k8s.flaky:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: false,
ComponentName: "kyverno",
Message: "Helm failed (exit 1): timed out waiting for condition",
Actions: new List<string> { "Error: timed out" }));
DeployComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("timed out");
}
[Fact]
public async Task HandleAsync_PassesOptionsToInstaller()
{
// Arrange — Deploy security policies with custom parameters.
KubernetesCluster cluster = KubernetesCluster.Register(
"config-cluster", "https://k8s.cfg:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ComponentInstallOptions? capturedOptions = null;
policiesInstaller.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.Callback<KubernetesCluster, ComponentInstallOptions, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new InstallResult(true, "security-policies", "Done", new List<string>()));
DeployComponentRequest request = new(
"security-policies",
Parameters: new Dictionary<string, string> { ["validationAction"] = "Enforce" });
// Act
await handler.HandleAsync(cluster.Id, request);
// Assert — The installer received the custom parameters.
capturedOptions.Should().NotBeNull();
capturedOptions!.Parameters.Should().ContainKey("validationAction");
capturedOptions.Parameters!["validationAction"].Should().Be("Enforce");
}
}

View File

@@ -0,0 +1,324 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.DeployComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests the deploy handler with all new component installers registered.
/// Verifies that the handler correctly routes to the right installer based on
/// component name and passes parameters through.
/// </summary>
public class DeployNewComponentsTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> cnpgInstaller;
private readonly Mock<IComponentInstaller> redisInstaller;
private readonly Mock<IComponentInstaller> certManagerInstaller;
private readonly Mock<IComponentInstaller> externalSecretsInstaller;
private readonly Mock<IComponentInstaller> rabbitmqInstaller;
private readonly Mock<IComponentInstaller> customDnsInstaller;
private readonly DeployComponentHandler handler;
public DeployNewComponentsTests()
{
repository = new InMemoryClusterRepository();
cnpgInstaller = new Mock<IComponentInstaller>();
cnpgInstaller.Setup(i => i.ComponentName).Returns("cnpg");
redisInstaller = new Mock<IComponentInstaller>();
redisInstaller.Setup(i => i.ComponentName).Returns("redis");
certManagerInstaller = new Mock<IComponentInstaller>();
certManagerInstaller.Setup(i => i.ComponentName).Returns("cert-manager");
externalSecretsInstaller = new Mock<IComponentInstaller>();
externalSecretsInstaller.Setup(i => i.ComponentName).Returns("external-secrets");
rabbitmqInstaller = new Mock<IComponentInstaller>();
rabbitmqInstaller.Setup(i => i.ComponentName).Returns("rabbitmq");
customDnsInstaller = new Mock<IComponentInstaller>();
customDnsInstaller.Setup(i => i.ComponentName).Returns("custom-dns");
List<IComponentInstaller> installers = new()
{
cnpgInstaller.Object,
redisInstaller.Object,
certManagerInstaller.Object,
externalSecretsInstaller.Object,
rabbitmqInstaller.Object,
customDnsInstaller.Object
};
handler = new DeployComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
}
[Fact]
public async Task HandleAsync_DeployCnpg_CallsInstaller()
{
// Arrange — A connected cluster wants CloudNativePG installed.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
cnpgInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cnpg", "CloudNativePG 0.22.0 installed", new List<string> { "Helm install" }));
DeployComponentRequest request = new("cnpg", Version: "0.22.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.ComponentName.Should().Be("cnpg");
cnpgInstaller.Verify(
i => i.InstallAsync(cluster, It.Is<ComponentInstallOptions>(o => o.Version == "0.22.0"), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_DeployRedis_PassesSentinelParameter()
{
// Arrange — Deploy Redis with Sentinel HA enabled.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
redisInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "redis", "Redis installed", new List<string> { "Helm install" }));
DeployComponentRequest request = new("redis", Parameters: new Dictionary<string, string>
{
["sentinelEnabled"] = "true",
["replicaCount"] = "3"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
redisInstaller.Verify(
i => i.InstallAsync(cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["sentinelEnabled"] == "true" &&
o.Parameters["replicaCount"] == "3"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_DeployCertManager_PassesLetsEncryptConfig()
{
// Arrange — Deploy cert-manager with Let's Encrypt + Cloudflare DNS01.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
certManagerInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cert-manager", "cert-manager installed with LE issuers",
new List<string> { "Helm install", "ClusterIssuer created" }));
DeployComponentRequest request = new("cert-manager", Parameters: new Dictionary<string, string>
{
["letsEncryptEmail"] = "admin@example.com",
["httpSolverEnabled"] = "true",
["dnsSolverEnabled"] = "true",
["dnsSolverProvider"] = "cloudflare",
["dnsSolverSecretName"] = "cloudflare-api-token"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
certManagerInstaller.Verify(
i => i.InstallAsync(cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["letsEncryptEmail"] == "admin@example.com" &&
o.Parameters["dnsSolverProvider"] == "cloudflare"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_DeployExternalSecrets_PassesVaultConfig()
{
// Arrange — Deploy ESO with Vault as the secret store provider.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
externalSecretsInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "external-secrets", "ESO installed",
new List<string> { "Helm install", "ClusterSecretStore created" }));
DeployComponentRequest request = new("external-secrets", Parameters: new Dictionary<string, string>
{
["storeProvider"] = "vault",
["storeEndpoint"] = "http://vault.vault:8200"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
externalSecretsInstaller.Verify(
i => i.InstallAsync(cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["storeProvider"] == "vault" &&
o.Parameters["storeEndpoint"] == "http://vault.vault:8200"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_DeployRabbitMQ_PassesTopologyOperatorParam()
{
// Arrange — Deploy RabbitMQ with messaging topology operator.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
rabbitmqInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "rabbitmq", "RabbitMQ operator installed",
new List<string> { "Helm install", "Topology operator enabled" }));
DeployComponentRequest request = new("rabbitmq", Parameters: new Dictionary<string, string>
{
["topologyOperator"] = "true",
["monitoringEnabled"] = "true"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
rabbitmqInstaller.Verify(
i => i.InstallAsync(cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["topologyOperator"] == "true"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_DisconnectedCluster_ReturnsFailure()
{
// Arrange — A cluster that isn't connected can't have components deployed.
KubernetesCluster cluster = KubernetesCluster.Register(
"offline-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
DeployComponentRequest request = new("cnpg");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_InstallerFails_ReturnsFailure()
{
// Arrange — The installer reports a failure.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
redisInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(false, "redis", "Helm timeout", new List<string> { "Error: timeout" }));
DeployComponentRequest request = new("redis");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Helm timeout");
}
[Fact]
public async Task HandleAsync_DeployCustomDns_PassesStubZonesAndRecords()
{
// Arrange — Deploy custom DNS with stub zones and static records.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
customDnsInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "custom-dns", "CoreDNS customized",
new List<string> { "Stub zones configured", "Custom records applied" }));
DeployComponentRequest request = new("custom-dns", Parameters: new Dictionary<string, string>
{
["stubZones"] = "corp.internal=10.0.0.53,10.0.0.54;db.internal=10.1.0.53",
["customRecords"] = "api.local=192.168.1.100;cache.local=192.168.1.101"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.ComponentName.Should().Be("custom-dns");
customDnsInstaller.Verify(
i => i.InstallAsync(cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["stubZones"] == "corp.internal=10.0.0.53,10.0.0.54;db.internal=10.1.0.53" &&
o.Parameters["customRecords"] == "api.local=192.168.1.100;cache.local=192.168.1.101"),
It.IsAny<CancellationToken>()),
Times.Once);
}
}

View File

@@ -0,0 +1,162 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.DiscoverPrometheus;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class DiscoverPrometheusHandlerTests
{
private readonly InMemoryClusterRepository repository;
public DiscoverPrometheusHandlerTests()
{
repository = new InMemoryClusterRepository();
}
[Fact]
public async Task HandleAsync_WithDiscoveredEndpoints_StoresThemOnCluster()
{
// Arrange — A registered cluster and a scanner that finds two Prometheus instances.
KubernetesCluster cluster = KubernetesCluster.Register(
"production",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"prod-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
List<PrometheusEndpoint> fakeEndpoints = new()
{
new PrometheusEndpoint("http://prometheus.monitoring:9090", "monitoring", "prometheus-server"),
new PrometheusEndpoint("http://prometheus.observability:9090", "observability", "prom-stack")
};
FakePrometheusScanner scanner = new(fakeEndpoints);
DiscoverPrometheusHandler handler = new(repository, scanner);
// Act — Run discovery on the cluster.
Result<List<PrometheusEndpoint>> result = await handler.HandleAsync(cluster.Id);
// Assert — Both endpoints should be stored and returned.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
result.Value![0].Namespace.Should().Be("monitoring");
result.Value[1].Namespace.Should().Be("observability");
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.PrometheusEndpoints.Should().HaveCount(2);
}
[Fact]
public async Task HandleAsync_WithNoPrometheus_ReturnsEmptyList()
{
// Arrange — A cluster with no Prometheus running.
KubernetesCluster cluster = KubernetesCluster.Register(
"staging",
"https://staging.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"staging-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
FakePrometheusScanner scanner = new(new List<PrometheusEndpoint>());
DiscoverPrometheusHandler handler = new(repository, scanner);
// Act
Result<List<PrometheusEndpoint>> result = await handler.HandleAsync(cluster.Id);
// Assert — Success but empty list. No Prometheus found is not an error.
result.IsSuccess.Should().BeTrue();
result.Value.Should().BeEmpty();
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Arrange
FakePrometheusScanner scanner = new(new List<PrometheusEndpoint>());
DiscoverPrometheusHandler handler = new(repository, scanner);
// Act
Result<List<PrometheusEndpoint>> result = await handler.HandleAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_ReplacesExistingEndpoints_OnRediscovery()
{
// Arrange — A cluster that already had endpoints from a previous scan.
KubernetesCluster cluster = KubernetesCluster.Register(
"production",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"prod-ctx", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://old-prometheus:9090", "legacy", "old-prom")
});
await repository.AddAsync(cluster);
// A new scan finds a different Prometheus.
List<PrometheusEndpoint> newEndpoints = new()
{
new PrometheusEndpoint("http://prometheus.monitoring:9090", "monitoring", "prometheus-server")
};
FakePrometheusScanner scanner = new(newEndpoints);
DiscoverPrometheusHandler handler = new(repository, scanner);
// Act
Result<List<PrometheusEndpoint>> result = await handler.HandleAsync(cluster.Id);
// Assert — The old endpoint is replaced by the new one.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(1);
result.Value![0].ServiceName.Should().Be("prometheus-server");
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.PrometheusEndpoints.Should().HaveCount(1);
}
}
/// <summary>
/// Test double that returns pre-configured Prometheus endpoints
/// without actually connecting to a Kubernetes cluster.
/// </summary>
public class FakePrometheusScanner : IPrometheusScanner
{
private readonly List<PrometheusEndpoint> endpoints;
public FakePrometheusScanner(List<PrometheusEndpoint> endpoints)
{
this.endpoints = endpoints;
}
public Task<List<PrometheusEndpoint>> ScanAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
return Task.FromResult(endpoints);
}
}

View File

@@ -0,0 +1,142 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Infrastructure;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Integration tests for the EF Core cluster repository. These tests use a real
/// SQLite database to verify that EF change tracking works correctly — something
/// the in-memory repository can't catch. The key scenario: adding a child entity
/// (StorageBucket) to an already-tracked aggregate and saving without a
/// DbUpdateConcurrencyException.
/// </summary>
public class EfClusterRepositoryTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ClustersDbContext db;
private readonly EfClusterRepository repository;
public EfClusterRepositoryTests()
{
// Use an in-memory SQLite database that persists for the test lifetime.
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ClustersDbContext> options = new DbContextOptionsBuilder<ClustersDbContext>()
.UseSqlite(connection)
.Options;
db = new ClustersDbContext(options);
db.Database.EnsureCreated();
repository = new EfClusterRepository(db);
}
[Fact]
public async Task UpdateAsync_AddingStorageBucket_DoesNotThrowConcurrencyException()
{
// Arrange — Register a cluster and persist it to the database.
// This simulates the real flow: a cluster already exists, and
// the user creates a storage bucket that needs to be saved.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s:6443", "kubeconfig-data",
Guid.NewGuid(), "test-ctx", Guid.NewGuid());
cluster.MarkConnected();
cluster.SetProvider(CloudProvider.Cleura, new ProviderCredentials("user", "pass", "eu-north-1"));
await repository.AddAsync(cluster);
// Act — Load the cluster back, add a new storage bucket, then save.
// Before the fix, db.Clusters.Update() would mark the new bucket as
// Modified instead of Added, causing DbUpdateConcurrencyException.
KubernetesCluster? loaded = await repository.GetByIdAsync(cluster.Id);
loaded.Should().NotBeNull();
StorageBucket bucket = StorageBucket.Create(
"my-encrypted-bucket", "AKID", "SECRET", "https://s3.example.com", "eu-north-1", true);
loaded!.AddStorageBucket(bucket);
Func<Task> act = async () => await repository.UpdateAsync(loaded);
// Assert — Should save without throwing.
await act.Should().NotThrowAsync<DbUpdateConcurrencyException>();
// Verify the bucket was actually persisted.
KubernetesCluster? reloaded = await repository.GetByIdAsync(cluster.Id);
reloaded!.StorageBuckets.Should().HaveCount(1);
reloaded.StorageBuckets[0].Name.Should().Be("my-encrypted-bucket");
reloaded.StorageBuckets[0].Encrypted.Should().BeTrue();
}
[Fact]
public async Task UpdateAsync_ReplacingComponents_PersistsNewComponents()
{
// Arrange — Register a cluster, run a first adoption scan, and persist.
// This mirrors the real flow: adoption scan detects components, then
// a second scan replaces them with fresh results.
KubernetesCluster cluster = KubernetesCluster.Register(
"scan-cluster", "https://k8s:6443", "kubeconfig-data",
Guid.NewGuid(), "scan-ctx", Guid.NewGuid());
cluster.MarkConnected();
// Simulate first adoption scan — detect kyverno as installed.
List<ComponentCheckResult> firstScan = new()
{
new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running" }, new List<string>(),
new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno", new Dictionary<string, string>()))
};
cluster.UpdateComponents(firstScan);
await repository.AddAsync(cluster);
// Act — Load the cluster, run a second scan with different results,
// and save. This is the adoption flow: UpdateComponents replaces the
// backing field, then UpdateAsync persists the change.
KubernetesCluster? loaded = await repository.GetByIdAsync(cluster.Id);
loaded.Should().NotBeNull();
loaded!.Components.Should().HaveCount(1);
List<ComponentCheckResult> secondScan = new()
{
new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running" }, new List<string>(),
new DiscoveredConfiguration("3.8.0", "kyverno", "kyverno", new Dictionary<string, string>())),
new ComponentCheckResult("cert-manager", ComponentStatus.NotInstalled,
new List<string>(), new List<string> { "No pods found" })
};
loaded.UpdateComponents(secondScan);
Func<Task> act = async () => await repository.UpdateAsync(loaded);
// Assert — Should save without error and persist both components.
await act.Should().NotThrowAsync();
KubernetesCluster? reloaded = await repository.GetByIdAsync(cluster.Id);
reloaded!.Components.Should().HaveCount(2);
reloaded.Components.Should().Contain(c => c.ComponentName == "kyverno" && c.Version == "3.8.0");
reloaded.Components.Should().Contain(c => c.ComponentName == "cert-manager");
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
}

View File

@@ -0,0 +1,61 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.GetClusterById;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class GetClusterByIdHandlerTests
{
private readonly GetClusterByIdHandler handler;
private readonly InMemoryClusterRepository repository;
public GetClusterByIdHandlerTests()
{
repository = new InMemoryClusterRepository();
handler = new GetClusterByIdHandler(repository);
}
[Fact]
public async Task HandleAsync_WithExistingCluster_ReturnsSuccess()
{
// Arrange — Register a cluster so the repository has something to find.
KubernetesCluster cluster = KubernetesCluster.Register(
"production-eu",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"prod-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act — Look it up by its ID.
Result<KubernetesCluster> result = await handler.HandleAsync(cluster.Id);
// Assert — The handler should return the full cluster aggregate.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value!.Name.Should().Be("production-eu");
}
[Fact]
public async Task HandleAsync_WithNonExistentId_ReturnsFailure()
{
// Arrange — An ID that doesn't exist in the repository.
Guid unknownId = Guid.NewGuid();
// Act
Result<KubernetesCluster> result = await handler.HandleAsync(unknownId);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,348 @@
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.Components.Gitea;
using EntKube.Clusters.Infrastructure.Cleura;
using FluentAssertions;
using k8s.Models;
using Moq;
namespace EntKube.Clusters.Tests.Features;
public class GiteaComponentTests
{
[Fact]
public void GiteaInstaller_HasCorrectComponentName()
{
// The installer's ComponentName must match the check's ComponentName
// so the coordinator can pair them correctly.
GiteaInstaller installer = new(
new Microsoft.Extensions.Logging.Abstractions.NullLogger<GiteaInstaller>(),
new CleuraS3Client(Mock.Of<IHttpClientFactory>()),
Mock.Of<IHttpClientFactory>());
installer.ComponentName.Should().Be("gitea");
}
[Fact]
public void GiteaCheck_HasCorrectComponentName()
{
// The check's ComponentName identifies which component is being evaluated
// during cluster adoption scans.
GiteaCheck check = new(new Microsoft.Extensions.Logging.Abstractions.NullLogger<GiteaCheck>());
check.ComponentName.Should().Be("gitea");
}
[Fact]
public void GiteaCheck_HasDisplayName()
{
// The display name is shown in the UI so users can identify the component.
GiteaCheck check = new(new Microsoft.Extensions.Logging.Abstractions.NullLogger<GiteaCheck>());
check.DisplayName.Should().NotBeNullOrWhiteSpace();
check.DisplayName.Should().Contain("Gitea");
}
[Fact]
public void GiteaSchema_IsRegistered()
{
// The configuration schema must be registered so the UI can render
// typed configuration forms for the Gitea component.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.ComponentName.Should().Be("gitea");
schema.Parameters.Should().NotBeEmpty();
}
[Fact]
public void GiteaSchema_ContainsReplicasParameter()
{
// The replicas parameter controls how many Gitea pods are deployed.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "replicas");
}
[Fact]
public void GiteaSchema_ContainsActionsParameter()
{
// Gitea Actions (CI/CD) is a key feature that can be toggled.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "actionsEnabled");
}
[Fact]
public void GiteaSchema_ContainsStorageParameter()
{
// Repository persistence size is configurable for capacity planning.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "persistenceSize");
}
[Fact]
public void GiteaSchema_ContainsSshParameter()
{
// SSH access can be enabled/disabled depending on security requirements.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "sshEnabled");
}
[Fact]
public void GiteaInstaller_GetSchema_ReturnsGiteaSchema()
{
// The installer's default GetSchema() implementation should delegate
// to ComponentSchemas and return the gitea schema.
IComponentInstaller installer = new GiteaInstaller(
new Microsoft.Extensions.Logging.Abstractions.NullLogger<GiteaInstaller>(),
new CleuraS3Client(Mock.Of<IHttpClientFactory>()),
Mock.Of<IHttpClientFactory>());
ConfigurationSchema schema = installer.GetSchema();
schema.ComponentName.Should().Be("gitea");
schema.Parameters.Should().NotBeEmpty();
}
[Fact]
public void BuildActRunnerStatefulSet_DefaultReplicas_ReturnsValidStatefulSet()
{
// Arrange & Act
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 2, "10Gi");
// Assert
sts.Metadata.Name.Should().Be("act-runner");
sts.Metadata.NamespaceProperty.Should().Be("gitea");
sts.Spec.Replicas.Should().Be(2);
sts.Spec.ServiceName.Should().Be("act-runner");
}
[Fact]
public void BuildActRunnerStatefulSet_UsesCorrectImage()
{
// Arrange & Act
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi");
// Assert
V1Container container = sts.Spec.Template.Spec.Containers[0];
container.Image.Should().Be("gitea/act_runner:latest-dind-rootless");
container.Name.Should().Be("runner");
}
[Fact]
public void BuildActRunnerStatefulSet_SetsEnvironmentVariables()
{
// Arrange & Act
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi");
// Assert — runner needs DOCKER_HOST and GITEA_INSTANCE_URL at minimum
V1Container container = sts.Spec.Template.Spec.Containers[0];
container.Env.Should().Contain(e => e.Name == "DOCKER_HOST");
container.Env.Should().Contain(e => e.Name == "GITEA_INSTANCE_URL");
container.Env.Should().Contain(e => e.Name == "GITEA_RUNNER_REGISTRATION_TOKEN");
}
[Fact]
public void BuildActRunnerStatefulSet_ConfiguresPvcWithRequestedSize()
{
// Arrange & Act
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 3, "20Gi");
// Assert
V1PersistentVolumeClaim pvc = sts.Spec.VolumeClaimTemplates[0];
pvc.Metadata.Name.Should().Be("runner-data");
pvc.Spec.Resources.Requests["storage"].ToString().Should().Be("20Gi");
}
[Fact]
public void BuildActRunnerStatefulSet_WithHarborUrl_SetsRegistryMirrorEnv()
{
// Arrange & Act — provide a Harbor registry URL for the runners.
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi", "harbor.example.com");
// Assert — the Docker daemon should be configured to mirror through Harbor.
V1Container container = sts.Spec.Template.Spec.Containers[0];
container.Env.Should().Contain(e =>
e.Name == "DOCKERD_ROOTLESS_FLAGS" &&
e.Value == "--registry-mirror=https://harbor.example.com");
}
[Fact]
public void BuildActRunnerStatefulSet_WithoutHarborUrl_NoMirrorEnv()
{
// Arrange & Act — no Harbor URL, runners pull images directly.
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi");
// Assert — no Docker daemon mirror flag should be present.
V1Container container = sts.Spec.Template.Spec.Containers[0];
container.Env.Should().NotContain(e => e.Name == "DOCKERD_ROOTLESS_FLAGS");
}
[Fact]
public void GiteaSchema_ContainsCnpgClusterNameParameter()
{
// Gitea should reference the shared CNPG cluster for its database.
// The cnpgClusterName parameter tells the installer which CNPG cluster
// to create the gitea database on, instead of requiring manual dbHost.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p =>
p.Key == "cnpgClusterName" && p.Type == ParameterType.PostgresCluster);
}
[Fact]
public void GiteaSchema_ContainsCnpgNamespaceParameter()
{
// The namespace where the CNPG cluster lives — needed to resolve
// the service endpoint ({clusterName}-rw.{namespace}.svc).
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "cnpgNamespace");
}
[Fact]
public void GiteaSchema_IngressProviderIsSelect()
{
// Ingress provider should be a dropdown selector, not a free-text
// field, so users pick from the supported providers.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
ConfigurationParameter ingressProvider = schema.Parameters.First(p => p.Key == "ingressProvider");
ingressProvider.Type.Should().Be(ParameterType.Select);
ingressProvider.AllowedValues.Should().Contain("traefik");
ingressProvider.AllowedValues.Should().Contain("istio");
ingressProvider.AllowedValues.Should().Contain("gatewayapi");
}
[Fact]
public void GiteaSchema_RedisClusterIsRedisClusterType()
{
// Redis should be a cluster selector dropdown, not a manual host
// textbox. The UI populates it from provisioned Redis clusters.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p =>
p.Key == "redisClusterName" && p.Type == ParameterType.RedisCluster);
}
[Fact]
public void GiteaSchema_DatabaseFieldsDoNotIncludeManualHostUserPassword()
{
// With the CNPG cluster selector, manual dbHost/dbUser/dbPassword
// fields are no longer needed — credentials are auto-generated
// and stored in the secrets vault.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().NotContain(p => p.Key == "dbHost");
schema.Parameters.Should().NotContain(p => p.Key == "dbUser");
schema.Parameters.Should().NotContain(p => p.Key == "dbPassword");
}
[Fact]
public void GiteaSchema_ContainsOidcParameters()
{
// OIDC integration allows Gitea to authenticate users via an external
// identity provider like Keycloak.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "oidcEnabled" && p.Type == ParameterType.Boolean);
schema.Parameters.Should().Contain(p => p.Key == "oidcDiscoveryUrl");
schema.Parameters.Should().Contain(p => p.Key == "oidcClientId");
schema.Parameters.Should().Contain(p => p.Key == "oidcClientSecret");
}
[Fact]
public void GiteaSchema_OidcFieldsDependOnOidcEnabled()
{
// OIDC-specific fields should only be visible when oidcEnabled is true.
// This keeps the UI clean for installations without OIDC.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
ConfigurationParameter discoveryUrl = schema.Parameters.First(p => p.Key == "oidcDiscoveryUrl");
discoveryUrl.DependsOnKey.Should().Be("oidcEnabled");
discoveryUrl.DependsOnValues.Should().Contain("true");
}
[Fact]
public void GiteaSchema_ObjectStorageCascading()
{
// Object storage fields should cascade based on the objectStorageType
// selection — selecting "bucket" shows the bucket selector, "s3" shows
// manual S3 fields, "azure" shows Azure fields, "none" hides everything.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
ConfigurationParameter storageType = schema.Parameters.First(p => p.Key == "objectStorageType");
storageType.Type.Should().Be(ParameterType.Select);
storageType.AllowedValues.Should().Contain("none");
storageType.AllowedValues.Should().Contain("bucket");
storageType.AllowedValues.Should().Contain("s3");
storageType.AllowedValues.Should().Contain("azure");
ConfigurationParameter bucket = schema.Parameters.First(p => p.Key == "storageBucketId");
bucket.DependsOnKey.Should().Be("objectStorageType");
bucket.DependsOnValues.Should().Contain("bucket");
ConfigurationParameter s3Endpoint = schema.Parameters.First(p => p.Key == "s3Endpoint");
s3Endpoint.DependsOnKey.Should().Be("objectStorageType");
s3Endpoint.DependsOnValues.Should().Contain("s3");
ConfigurationParameter azureName = schema.Parameters.First(p => p.Key == "azureAccountName");
azureName.DependsOnKey.Should().Be("objectStorageType");
azureName.DependsOnValues.Should().Contain("azure");
}
[Fact]
public void GiteaSchema_ContainsVersionParameter()
{
// Users should be able to select which version of Gitea to install
// or upgrade to.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "version");
}
[Fact]
public void GiteaSchema_ContainsProjectsParameter()
{
// Projects (kanban boards) should be configurable per installation.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p =>
p.Key == "projectsEnabled" && p.Type == ParameterType.Boolean);
}
[Fact]
public void GiteaSchema_SshPortDependsOnSshEnabled()
{
// SSH port should only be visible when SSH is enabled.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
ConfigurationParameter sshPort = schema.Parameters.First(p => p.Key == "sshPort");
sshPort.DependsOnKey.Should().Be("sshEnabled");
sshPort.DependsOnValues.Should().Contain("true");
}
}

View File

@@ -0,0 +1,728 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.Components;
using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
using EntKube.Clusters.Features.AdoptCluster.DeployComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using k8s.Models;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the Harbor container registry component. Harbor provides:
///
/// 1. **Container registry** — hosting private container images
/// 2. **Helm chart repository** — hosting Helm charts via OCI
/// 3. **Pull-through cache** — caching upstream registries (Docker Hub, ghcr.io, quay.io, etc.)
///
/// The pull-through cache is especially valuable: all services and tenant apps
/// pull images through Harbor, gaining caching, vulnerability scanning, and
/// network isolation from public registries.
///
/// Dependency chain: ingress (for Harbor UI/API) → cert-manager (TLS) → Harbor
/// </summary>
public class HarborTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> harborInstaller;
public HarborTests()
{
repository = new InMemoryClusterRepository();
harborInstaller = new Mock<IComponentInstaller>();
harborInstaller.Setup(i => i.ComponentName).Returns("harbor");
}
// ─── Harbor Installation ──────────────────────────────────────────────────
[Fact]
public async Task Harbor_Install_DeploysRegistryWithPullThroughCache()
{
// Arrange — We want a full Harbor deployment with pull-through proxy cache
// enabled for major upstream registries. This gives us a local mirror that
// speeds up pulls and survives upstream outages.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Harbor 2.12.0 installed with pull-through cache for docker.io, ghcr.io, quay.io",
Actions: new List<string>
{
"Ensured namespace 'harbor'",
"Helm install harbor v2.12.0",
"Created proxy cache project 'dockerhub-cache' → docker.io",
"Created proxy cache project 'ghcr-cache' → ghcr.io",
"Created proxy cache project 'quay-cache' → quay.io",
"Harbor registry available at registry.internal.corp.com"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
harborInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("harbor", Version: "2.12.0",
Parameters: new Dictionary<string, string>
{
["harborDomain"] = "registry.internal.corp.com",
["adminPassword"] = "Harbor12345",
["storageClass"] = "longhorn",
["storageSize"] = "100Gi",
["cacheRegistries"] = "docker.io,ghcr.io,quay.io",
["enableChartMuseum"] = "true"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Harbor deployed with proxy cache projects for each registry.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("Helm install harbor"));
result.Value.Actions.Should().Contain(a => a.Contains("dockerhub-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("ghcr-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("quay-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("registry.internal.corp.com"));
harborInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["harborDomain"] == "registry.internal.corp.com" &&
o.Parameters["cacheRegistries"] == "docker.io,ghcr.io,quay.io"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Harbor_Install_WithTLS_UsesClusterIssuerForCertificate()
{
// Arrange — Harbor should use a cert-manager ClusterIssuer to auto-provision
// TLS certificates. This integrates with the internal CA or domain CA
// we set up earlier.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Harbor 2.12.0 installed with TLS via ClusterIssuer 'platform-internal-ca'",
Actions: new List<string>
{
"Ensured namespace 'harbor'",
"Helm install harbor v2.12.0",
"Configured TLS via ClusterIssuer 'platform-internal-ca'",
"Created Ingress for registry.internal.corp.com with TLS",
"Harbor registry available at registry.internal.corp.com"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
harborInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("harbor", Version: "2.12.0",
Parameters: new Dictionary<string, string>
{
["harborDomain"] = "registry.internal.corp.com",
["adminPassword"] = "Harbor12345",
["storageClass"] = "longhorn",
["storageSize"] = "50Gi",
["tlsIssuer"] = "platform-internal-ca",
["enableTLS"] = "true"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — TLS configured via cert-manager integration.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("TLS via ClusterIssuer"));
result.Value.Actions.Should().Contain(a => a.Contains("Ingress"));
}
[Fact]
public async Task Harbor_Install_WithoutPrerequisites_ReportsFailure()
{
// Arrange — Harbor requires ingress to be accessible. If the cluster
// has no ingress controller, the install should fail gracefully.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: false,
ComponentName: "harbor",
Message: "Harbor requires an ingress controller but none was detected",
Actions: new List<string> { "Prerequisite check failed: no ingress controller found" }));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
harborInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("harbor",
Parameters: new Dictionary<string, string>
{
["harborDomain"] = "registry.internal.corp.com",
["adminPassword"] = "Harbor12345"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Prerequisite failure reported.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("ingress");
}
// ─── Harbor Configuration ─────────────────────────────────────────────────
[Fact]
public async Task Harbor_Configure_AddProxyCacheRegistry()
{
// Arrange — Harbor is running. We want to add a new upstream registry
// as a proxy cache (e.g., registry.k8s.io for Kubernetes images).
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Proxy cache project added for registry.k8s.io",
Actions: new List<string>
{
"Created proxy cache project 'k8s-registry-cache' → registry.k8s.io",
"Proxy cache now covers: docker.io, ghcr.io, quay.io, registry.k8s.io"
}));
List<IComponentInstaller> installers = new() { harborInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "harbor",
Values: new Dictionary<string, string>
{
["addProxyCache"] = "registry.k8s.io",
["proxyCacheProjectName"] = "k8s-registry-cache"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — New proxy cache project created.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("registry.k8s.io"));
result.Value.Actions.Should().Contain(a => a.Contains("k8s-registry-cache"));
}
[Fact]
public async Task Harbor_Configure_SetAsDefaultPullThroughCache()
{
// Arrange — We want to configure the cluster so all pod image pulls
// go through Harbor as a mirror/cache. This typically involves configuring
// containerd mirror settings or using Kyverno to mutate image references.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Cluster configured to use Harbor as default pull-through cache",
Actions: new List<string>
{
"Created Kyverno ClusterPolicy 'harbor-image-mirror' to rewrite image references",
"Policy rewrites docker.io/* → registry.internal.corp.com/dockerhub-cache/*",
"Policy rewrites ghcr.io/* → registry.internal.corp.com/ghcr-cache/*",
"Policy rewrites quay.io/* → registry.internal.corp.com/quay-cache/*"
}));
List<IComponentInstaller> installers = new() { harborInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "harbor",
Values: new Dictionary<string, string>
{
["setDefaultCache"] = "true",
["harborDomain"] = "registry.internal.corp.com",
["cacheStrategy"] = "kyverno-rewrite"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — Kyverno policy created to rewrite image refs through Harbor.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("ClusterPolicy"));
result.Value.Actions.Should().Contain(a => a.Contains("docker.io"));
result.Value.Actions.Should().Contain(a => a.Contains("ghcr.io"));
}
[Fact]
public async Task Harbor_Configure_CreatePrivateProject()
{
// Arrange — Create a private project in Harbor for tenant images.
// Each tenant gets their own project with isolation.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Private project 'tenant-alpha' created",
Actions: new List<string>
{
"Created private project 'tenant-alpha'",
"Created robot account 'robot$tenant-alpha-pull' with pull access",
"Created image pull Secret 'harbor-pull-tenant-alpha' in namespace 'tenant-alpha'"
}));
List<IComponentInstaller> installers = new() { harborInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "harbor",
Values: new Dictionary<string, string>
{
["createProject"] = "tenant-alpha",
["projectVisibility"] = "private",
["createPullSecret"] = "true",
["targetNamespace"] = "tenant-alpha"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — Project, robot account, and pull secret created.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("private project 'tenant-alpha'"));
result.Value.Actions.Should().Contain(a => a.Contains("robot account"));
result.Value.Actions.Should().Contain(a => a.Contains("pull Secret"));
}
[Fact]
public async Task Harbor_Configure_EnableVulnerabilityScanning()
{
// Arrange — Enable automatic vulnerability scanning on all pushed images.
// Harbor uses Trivy for scanning. We want to configure the scan policy.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Vulnerability scanning configured",
Actions: new List<string>
{
"Enabled automatic scan on push for all projects",
"Set vulnerability severity threshold to 'High'",
"Configured scan schedule: daily at 02:00 UTC"
}));
List<IComponentInstaller> installers = new() { harborInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "harbor",
Values: new Dictionary<string, string>
{
["enableScanOnPush"] = "true",
["severityThreshold"] = "High",
["scanSchedule"] = "0 2 * * *"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — Scanning policies configured.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("scan on push"));
result.Value.Actions.Should().Contain(a => a.Contains("severity threshold"));
}
// ─── Wire All Platform Services ──────────────────────────────────────────
[Fact]
public async Task Harbor_Configure_WireAllServices_CreatesComprehensiveCacheAndPolicy()
{
// Arrange — Harbor is running. We want to wire ALL platform services
// (MinIO, CloudNativePG, cert-manager, Kyverno, Istio, Grafana, Prometheus,
// Redis, RabbitMQ, External Secrets) to pull images through Harbor.
// This creates proxy cache projects for every upstream registry used by
// our services and a Kyverno policy to rewrite image references.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "All platform services wired to pull through Harbor",
Actions: new List<string>
{
"Ensured proxy cache project 'dockerhub-cache' → docker.io (MinIO, Istio, Grafana, Redis, RabbitMQ)",
"Ensured proxy cache project 'ghcr-cache' → ghcr.io (CloudNativePG, Kyverno, External Secrets)",
"Ensured proxy cache project 'quay-cache' → quay.io (cert-manager, trust-manager, Prometheus)",
"Ensured proxy cache project 'k8s-cache' → registry.k8s.io (CoreDNS, metrics-server)",
"Ensured proxy cache project 'gcr-cache' → gcr.io (Istio release images)",
"Created Kyverno ClusterPolicy 'harbor-image-mirror' to rewrite image references",
"Policy rewrites docker.io/* → registry.internal.corp.com/dockerhub-cache/*",
"Policy rewrites ghcr.io/* → registry.internal.corp.com/ghcr-cache/*",
"Policy rewrites quay.io/* → registry.internal.corp.com/quay-cache/*",
"Policy rewrites registry.k8s.io/* → registry.internal.corp.com/k8s-cache/*",
"Policy rewrites gcr.io/* → registry.internal.corp.com/gcr-cache/*",
"All 12 platform services now pull through Harbor"
}));
List<IComponentInstaller> installers = new() { harborInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "harbor",
Values: new Dictionary<string, string>
{
["wireAllServices"] = "true",
["harborDomain"] = "registry.internal.corp.com"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — All registries cached and policy covers all platforms.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("docker.io") && a.Contains("MinIO"));
result.Value.Actions.Should().Contain(a => a.Contains("ghcr.io") && a.Contains("CloudNativePG"));
result.Value.Actions.Should().Contain(a => a.Contains("quay.io") && a.Contains("cert-manager"));
result.Value.Actions.Should().Contain(a => a.Contains("registry.k8s.io"));
result.Value.Actions.Should().Contain(a => a.Contains("gcr.io"));
result.Value.Actions.Should().Contain(a => a.Contains("ClusterPolicy"));
result.Value.Actions.Should().Contain(a => a.Contains("12 platform services"));
harborInstaller.Verify(i => i.ConfigureAsync(
cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["wireAllServices"] == "true" &&
c.Values["harborDomain"] == "registry.internal.corp.com"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Harbor_Install_WithAllServices_IncludesAllRegistryCaches()
{
// Arrange — When installing Harbor with wireAllServices=true, it should
// automatically create proxy caches for all 5 upstream registries
// needed by platform services, not just the default 3.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Harbor 2.12.0 installed with pull-through cache for all platform registries",
Actions: new List<string>
{
"Ensured namespace 'harbor'",
"Helm install harbor v2.12.0",
"Created proxy cache project 'dockerhub-cache' → docker.io",
"Created proxy cache project 'ghcr-cache' → ghcr.io",
"Created proxy cache project 'quay-cache' → quay.io",
"Created proxy cache project 'k8s-cache' → registry.k8s.io",
"Created proxy cache project 'gcr-cache' → gcr.io",
"Harbor registry available at registry.internal.corp.com"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
harborInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("harbor", Version: "2.12.0",
Parameters: new Dictionary<string, string>
{
["harborDomain"] = "registry.internal.corp.com",
["adminPassword"] = "Harbor12345",
["storageClass"] = "longhorn",
["storageSize"] = "100Gi",
["cacheRegistries"] = "docker.io,ghcr.io,quay.io,registry.k8s.io,gcr.io"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — All 5 registries have proxy cache projects.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("dockerhub-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("ghcr-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("quay-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("k8s-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("gcr-cache"));
}
// ─── Harbor Adoption Check ────────────────────────────────────────────────
[Fact]
public async Task Harbor_Check_DetectsExistingHarborDeployment()
{
// Arrange — A cluster already has Harbor installed via Helm.
// The adoption check should discover it, report version, and enumerate
// existing proxy cache projects.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IAdoptionCheck> harborCheck = new();
harborCheck.Setup(c => c.ComponentName).Returns("harbor");
harborCheck.Setup(c => c.DisplayName).Returns("Harbor (Container Registry & Helm Charts)");
harborCheck
.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult(
"harbor",
ComponentStatus.Installed,
Details: new List<string>
{
"Harbor 2.12.0 detected (Helm release 'harbor' in namespace 'harbor')",
"Registry endpoint: registry.internal.corp.com",
"Proxy cache projects: dockerhub-cache, ghcr-cache, quay-cache",
"Trivy scanner enabled",
"ChartMuseum enabled"
},
MissingItems: new List<string>(),
Configuration: new DiscoveredConfiguration(
Version: "2.12.0",
Namespace: "harbor",
HelmReleaseName: "harbor",
Values: new Dictionary<string, string>
{
["registryEndpoint"] = "registry.internal.corp.com",
["proxyCacheProjects"] = "dockerhub-cache,ghcr-cache,quay-cache",
["trivyEnabled"] = "true",
["chartMuseumEnabled"] = "true",
["storageClass"] = "longhorn",
["storageSize"] = "100Gi"
})));
// Act
ComponentCheckResult checkResult = await harborCheck.Object.CheckAsync(cluster);
// Assert — Harbor detected with full configuration discovery.
checkResult.Status.Should().Be(ComponentStatus.Installed);
checkResult.Configuration.Should().NotBeNull();
checkResult.Configuration!.Version.Should().Be("2.12.0");
checkResult.Configuration.HelmReleaseName.Should().Be("harbor");
checkResult.Configuration.Values["proxyCacheProjects"].Should().Contain("dockerhub-cache");
checkResult.Details.Should().Contain(d => d.Contains("Trivy"));
}
[Fact]
public async Task Harbor_Check_DetectsPartialInstallation()
{
// Arrange — Harbor is installed but some pods are not healthy (e.g.,
// the database pod is in CrashLoopBackOff). The check reports Degraded.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IAdoptionCheck> harborCheck = new();
harborCheck.Setup(c => c.ComponentName).Returns("harbor");
harborCheck.Setup(c => c.DisplayName).Returns("Harbor (Container Registry & Helm Charts)");
harborCheck
.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult(
"harbor",
ComponentStatus.Degraded,
Details: new List<string>
{
"Harbor Helm release found in namespace 'harbor'",
"Core component: Running",
"Registry component: Running",
"Database component: CrashLoopBackOff"
},
MissingItems: new List<string>
{
"harbor-database pod unhealthy (CrashLoopBackOff)"
}));
// Act
ComponentCheckResult checkResult = await harborCheck.Object.CheckAsync(cluster);
// Assert — Degraded status with details about what's wrong.
checkResult.Status.Should().Be(ComponentStatus.Degraded);
checkResult.MissingItems.Should().Contain(m => m.Contains("database"));
checkResult.Details.Should().Contain(d => d.Contains("CrashLoopBackOff"));
}
private static KubernetesCluster CreateConnectedCluster()
{
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443",
"apiVersion: v1\nkind: Config\nclusters: []\nusers: []\ncontexts: []",
Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
return cluster;
}
// ─── CNPG Database Integration ────────────────────────────────────────────
[Fact]
public void HarborSchema_ContainsCnpgClusterNameParameter()
{
// Harbor should be able to reference the shared CNPG cluster for its
// database instead of using the embedded PostgreSQL sub-chart.
ConfigurationSchema schema = ComponentSchemas.GetSchema("harbor");
schema.Parameters.Should().Contain(p =>
p.Key == "cnpgClusterName" && p.Type == ParameterType.PostgresCluster);
}
[Fact]
public void HarborSchema_ContainsCnpgNamespaceParameter()
{
// The CNPG cluster namespace is needed to resolve the primary service endpoint.
ConfigurationSchema schema = ComponentSchemas.GetSchema("harbor");
schema.Parameters.Should().Contain(p => p.Key == "cnpgNamespace");
}
// ─── Harbor Database Credentials ──────────────────────────────────────
[Fact]
public async Task Harbor_Install_WithCnpg_StoresCredentialsInVault()
{
// Arrange — When Harbor is installed with a CNPG cluster, the installer
// should store the generated database credentials in the vault at
// infrastructure/{clusterId}/harbor/database/* so they're auditable
// and recoverable.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
// Track vault calls made by the installer.
List<string> vaultPaths = new();
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Harbor installed with external CNPG database",
Actions: new List<string>
{
"Created database 'harbor' on CNPG cluster 'shared-pg'",
"Stored database credentials in Kubernetes Secret and vault",
"Helm install harbor v1.16.0"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
harborInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("harbor",
Parameters: new Dictionary<string, string>
{
["harborDomain"] = "registry.dev.example.com",
["cnpgClusterName"] = "shared-pg",
["cnpgNamespace"] = "cnpg-system"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — The install result should mention both the K8s Secret and vault.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("Stored database credentials"));
}
[Fact]
public void CnpgCredentialsSecret_ContainsAllRequiredKeys()
{
// When Harbor uses an external CNPG database, a K8s Secret is created
// with the database connection details. This secret must contain all
// four keys that Harbor expects: host, database, username, password.
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
"harbor-cnpg-credentials", "harbor",
"shared-pg-rw.cnpg-system.svc.cluster.local",
"harbor", "harbor", "secret-password-123");
secret.StringData.Should().ContainKey("host");
secret.StringData.Should().ContainKey("database");
secret.StringData.Should().ContainKey("username");
secret.StringData.Should().ContainKey("password");
secret.StringData["host"].Should().Be("shared-pg-rw.cnpg-system.svc.cluster.local");
secret.StringData["database"].Should().Be("harbor");
secret.StringData["username"].Should().Be("harbor");
secret.StringData["password"].Should().Be("secret-password-123");
}
[Fact]
public void CnpgCredentialsSecret_HasManagementLabels()
{
// The credentials secret should be labeled for EntKube management
// so it can be discovered and cleaned up.
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
"harbor-cnpg-credentials", "harbor",
"host", "harbor", "harbor", "pass");
secret.Metadata.Labels["app.kubernetes.io/managed-by"].Should().Be("entkube");
secret.Metadata.Labels["entkube.io/purpose"].Should().Be("database-credentials");
}
}

View File

@@ -0,0 +1,194 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.Components.Ingress;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for IngressCheck.DetectProvider — the static method that determines
/// which ingress provider is installed on a cluster by examining its component
/// list. This drives the auto-detection behavior so users never have to
/// manually select "istio" or "traefik" when configuring ingress.
/// </summary>
public class IngressCheckTests
{
[Fact]
public void DetectProvider_IstioInstalled_ReturnsIstio()
{
// When the cluster has an Istio component marked as Installed,
// the detector should pick Istio as the provider.
KubernetesCluster cluster = CreateClusterWithComponent("istio", ComponentStatus.Installed);
string? provider = IngressCheck.DetectProvider(cluster);
provider.Should().Be("istio");
}
[Fact]
public void DetectProvider_TraefikInstalled_ReturnsTraefik()
{
// When the cluster has a Traefik component marked as Installed,
// the detector should pick Traefik as the provider.
KubernetesCluster cluster = CreateClusterWithComponent("traefik", ComponentStatus.Installed);
string? provider = IngressCheck.DetectProvider(cluster);
provider.Should().Be("traefik");
}
[Fact]
public void DetectProvider_IstioDegraded_StillReturnsIstio()
{
// Even if Istio is degraded (partially working), we should still
// detect it as the provider — the gateway infra can still be deployed.
KubernetesCluster cluster = CreateClusterWithComponent("istio", ComponentStatus.Degraded);
string? provider = IngressCheck.DetectProvider(cluster);
provider.Should().Be("istio");
}
[Fact]
public void DetectProvider_NeitherInstalled_ReturnsNull()
{
// When no ingress controller is installed, the detector returns null
// so the installer can report "install Istio or Traefik first."
KubernetesCluster cluster = CreateClusterWithComponent("kyverno", ComponentStatus.Installed);
string? provider = IngressCheck.DetectProvider(cluster);
provider.Should().BeNull();
}
[Fact]
public void DetectProvider_BothInstalled_PrefersIstio()
{
// If both Istio and Traefik are installed (unusual but possible),
// Istio takes priority because it requires dedicated gateway infrastructure.
KubernetesCluster cluster = CreateClusterWithComponents(
("istio", ComponentStatus.Installed),
("traefik", ComponentStatus.Installed));
string? provider = IngressCheck.DetectProvider(cluster);
provider.Should().Be("istio");
}
// ─── Gateway resolution from ingress component ───────────────────────
[Fact]
public void IngressComponent_WithIstioGateways_ExposesGatewayInfo()
{
// When the ingress component is installed with Istio and has discovered
// internal and external gateways, the component's configuration should
// contain the gateway details that services can use to auto-resolve.
KubernetesCluster cluster = CreateClusterWithIngressConfig(
provider: "istio",
hasInternalGateway: true,
hasExternalGateway: true);
ClusterComponent? ingress = cluster.Components.FirstOrDefault(
c => c.ComponentName == "ingress");
ingress.Should().NotBeNull();
ingress!.Configuration["provider"].Should().Be("istio");
ingress.Configuration["hasInternalGateway"].Should().Be("True");
ingress.Configuration["hasExternalGateway"].Should().Be("True");
}
[Fact]
public void IngressComponent_WithTraefik_NoGatewayRefs()
{
// Traefik manages its own ingress — no Gateway API gateway refs needed.
// The component config should show traefik as provider.
KubernetesCluster cluster = CreateClusterWithIngressConfig(
provider: "traefik",
hasInternalGateway: false,
hasExternalGateway: false);
ClusterComponent? ingress = cluster.Components.FirstOrDefault(
c => c.ComponentName == "ingress");
ingress.Should().NotBeNull();
ingress!.Configuration["provider"].Should().Be("traefik");
ingress.Configuration.ContainsKey("hasInternalGateway").Should().BeFalse();
}
// ─── Helpers ─────────────────────────────────────────────────────────
private static KubernetesCluster CreateClusterWithComponent(string componentName, ComponentStatus status)
{
return CreateClusterWithComponents((componentName, status));
}
private static KubernetesCluster CreateClusterWithComponents(params (string Name, ComponentStatus Status)[] componentDefs)
{
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.example.com", "fake-kubeconfig",
Guid.NewGuid(), "test-context", Guid.NewGuid());
List<ComponentCheckResult> results = componentDefs
.Select(c => new ComponentCheckResult(
c.Name, c.Status,
Details: new List<string>(),
MissingItems: new List<string>()))
.ToList();
cluster.UpdateComponents(results);
return cluster;
}
/// <summary>
/// Creates a cluster with an ingress component that has discovered
/// configuration values — provider, gateway presence flags. This mimics
/// what the real IngressCheck discovers during an adoption scan.
/// </summary>
private static KubernetesCluster CreateClusterWithIngressConfig(
string provider, bool hasInternalGateway, bool hasExternalGateway)
{
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.example.com", "fake-kubeconfig",
Guid.NewGuid(), "test-context", Guid.NewGuid());
Dictionary<string, string> configValues = new()
{
["provider"] = provider
};
if (hasInternalGateway)
{
configValues["hasInternalGateway"] = "True";
}
if (hasExternalGateway)
{
configValues["hasExternalGateway"] = "True";
}
List<ComponentCheckResult> results = new()
{
new ComponentCheckResult(
"ingress", ComponentStatus.Installed,
Details: new List<string>(),
MissingItems: new List<string>(),
Configuration: new DiscoveredConfiguration(
Version: null,
Namespace: "internal-ingress",
HelmReleaseName: null,
Values: configValues))
};
cluster.UpdateComponents(results);
return cluster;
}
}

View File

@@ -0,0 +1,127 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster.Components.Ingress;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the ingress installer's provider-aware load balancer annotation
/// logic and Istio gateway Helm values generation. Each cloud provider requires
/// different Service annotations to create an internal (non-public) LoadBalancer.
/// These tests verify the correct annotations are produced for each provider.
/// </summary>
public class IngressInstallerTests
{
// ─── Internal LB Annotation Mapping ──────────────────────────────────
[Fact]
public void GetInternalLbAnnotations_Cleura_ReturnsOpenStackAnnotation()
{
// Cleura runs on OpenStack, which uses the openstack-internal-load-balancer
// annotation to tell the cloud controller to provision a private LB.
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Cleura);
annotations.Should().ContainKey("service.beta.kubernetes.io/openstack-internal-load-balancer");
annotations["service.beta.kubernetes.io/openstack-internal-load-balancer"].Should().Be("true");
annotations.Should().HaveCount(1);
}
[Fact]
public void GetInternalLbAnnotations_Aws_ReturnsAwsSchemeAnnotation()
{
// AWS uses the load-balancer-scheme annotation to create an internal NLB/ALB.
// The newer annotation supersedes the older aws-load-balancer-internal one.
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Aws);
annotations.Should().ContainKey("service.beta.kubernetes.io/aws-load-balancer-scheme");
annotations["service.beta.kubernetes.io/aws-load-balancer-scheme"].Should().Be("internal");
annotations.Should().HaveCount(1);
}
[Fact]
public void GetInternalLbAnnotations_Azure_ReturnsAzureAnnotation()
{
// Azure uses its own annotation to mark a LoadBalancer as internal.
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Azure);
annotations.Should().ContainKey("service.beta.kubernetes.io/azure-load-balancer-internal");
annotations["service.beta.kubernetes.io/azure-load-balancer-internal"].Should().Be("true");
annotations.Should().HaveCount(1);
}
[Fact]
public void GetInternalLbAnnotations_Gcp_ReturnsGcpAnnotation()
{
// GCP uses the networking.gke.io annotation for internal LBs.
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Gcp);
annotations.Should().ContainKey("networking.gke.io/load-balancer-type");
annotations["networking.gke.io/load-balancer-type"].Should().Be("Internal");
annotations.Should().HaveCount(1);
}
[Fact]
public void GetInternalLbAnnotations_None_ReturnsEmptyAnnotations()
{
// When no cloud provider is set, we can't determine the right annotation.
// Return empty and let the user handle it manually if needed.
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.None);
annotations.Should().BeEmpty();
}
// ─── Istio Gateway Helm Values ───────────────────────────────────────
[Fact]
public void GetIstioInternalGatewayValues_WithCleura_IncludesOpenStackAnnotation()
{
// When deploying to a Cleura cluster, the internal gateway's LoadBalancer
// Service must carry the OpenStack internal annotation so the cloud controller
// provisions a private LB accessible only from the internal network.
string values = IngressInstaller.GetIstioInternalGatewayValues(CloudProvider.Cleura);
values.Should().Contain("openstack-internal-load-balancer");
values.Should().NotContain("azure-load-balancer-internal");
values.Should().NotContain("aws-load-balancer");
}
[Fact]
public void GetIstioInternalGatewayValues_WithAws_IncludesAwsAnnotation()
{
string values = IngressInstaller.GetIstioInternalGatewayValues(CloudProvider.Aws);
values.Should().Contain("aws-load-balancer-scheme");
values.Should().Contain("internal");
values.Should().NotContain("azure-load-balancer-internal");
}
[Fact]
public void GetIstioInternalGatewayValues_WithNone_HasNoAnnotationsBlock()
{
// When no provider is set, the values should not include any internal
// LB annotations — the Service gets a plain public LoadBalancer.
string values = IngressInstaller.GetIstioInternalGatewayValues(CloudProvider.None);
values.Should().NotContain("annotations:");
values.Should().Contain("type: LoadBalancer");
}
[Fact]
public void GetIstioExternalGatewayValues_NeverIncludesInternalAnnotations()
{
// The external gateway should never have internal LB annotations
// regardless of the cloud provider — it's meant to be public.
string values = IngressInstaller.GetIstioExternalGatewayValues();
values.Should().NotContain("internal");
values.Should().Contain("type: LoadBalancer");
}
}

View File

@@ -0,0 +1,174 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.InstallPrometheus;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class InstallPrometheusHandlerTests
{
private readonly InMemoryClusterRepository repository;
public InstallPrometheusHandlerTests()
{
repository = new InMemoryClusterRepository();
}
[Fact]
public async Task HandleAsync_WithValidCluster_InstallsAndReturnsSuccess()
{
// Arrange — A cluster with no Prometheus. The user wants to install
// kube-prometheus-stack via Helm with a production configuration.
KubernetesCluster cluster = KubernetesCluster.Register(
"fresh-cluster",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"prod-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
FakeMonitoringInstaller installer = new(success: true);
InstallPrometheusHandler handler = new(repository, new List<IComponentInstaller> { installer });
// Act — Install Prometheus on the cluster.
Result<PrometheusInstallResult> result = await handler.HandleAsync(
new InstallPrometheusRequest(cluster.Id, "monitoring"));
// Assert — The install should succeed and the cluster should have
// the Prometheus endpoint stored.
result.IsSuccess.Should().BeTrue();
result.Value!.Namespace.Should().Be("monitoring");
result.Value.ReleaseName.Should().Be("kube-prometheus-stack");
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.PrometheusEndpoints.Should().HaveCount(1);
updated.PrometheusEndpoints[0].Namespace.Should().Be("monitoring");
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Arrange
FakeMonitoringInstaller installer = new(success: true);
InstallPrometheusHandler handler = new(repository, new List<IComponentInstaller> { installer });
// Act
Result<PrometheusInstallResult> result = await handler.HandleAsync(
new InstallPrometheusRequest(Guid.NewGuid(), "monitoring"));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_WhenHelmFails_ReturnsFailure()
{
// Arrange — Helm install fails (e.g., no connectivity, insufficient permissions).
KubernetesCluster cluster = KubernetesCluster.Register(
"broken-cluster",
"https://broken.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
FakeMonitoringInstaller installer = new(success: false, errorMessage: "Tiller not available");
InstallPrometheusHandler handler = new(repository, new List<IComponentInstaller> { installer });
// Act
Result<PrometheusInstallResult> result = await handler.HandleAsync(
new InstallPrometheusRequest(cluster.Id, "monitoring"));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Tiller not available");
}
[Fact]
public async Task HandleAsync_DefaultsToMonitoringNamespace()
{
// Arrange — The request doesn't specify a namespace, so it should
// default to "monitoring".
KubernetesCluster cluster = KubernetesCluster.Register(
"default-ns-cluster",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
FakeMonitoringInstaller installer = new(success: true);
InstallPrometheusHandler handler = new(repository, new List<IComponentInstaller> { installer });
// Act
Result<PrometheusInstallResult> result = await handler.HandleAsync(
new InstallPrometheusRequest(cluster.Id, null));
// Assert — Should install to "monitoring" namespace.
result.IsSuccess.Should().BeTrue();
result.Value!.Namespace.Should().Be("monitoring");
}
}
/// <summary>
/// Test double for the monitoring component installer. Returns pre-configured results
/// without actually running helm commands.
/// </summary>
public class FakeMonitoringInstaller : IComponentInstaller
{
private readonly bool success;
private readonly string? errorMessage;
public string ComponentName => "monitoring";
public FakeMonitoringInstaller(bool success, string? errorMessage = null)
{
this.success = success;
this.errorMessage = errorMessage;
}
public Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
if (success)
{
return Task.FromResult(new InstallResult(
true,
ComponentName,
"kube-prometheus-stack installed",
new List<string> { "Helm install succeeded" }));
}
return Task.FromResult(new InstallResult(
false,
ComponentName,
errorMessage ?? "Install failed",
new List<string> { $"Error: {errorMessage}" }));
}
public Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
return Task.FromResult(new InstallResult(
true,
ComponentName,
"Monitoring reconfigured",
new List<string> { "Configuration applied" }));
}
}

View File

@@ -0,0 +1,546 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
using EntKube.Clusters.Features.AdoptCluster.DeployComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the Keycloak identity provider component. Keycloak provides:
///
/// 1. **Multi-realm identity management** — each tenant or environment gets its own realm
/// 2. **BankID identity provider** — Swedish BankID as a login option per realm
/// 3. **Custom theming** — per-realm logos, colors, and backgrounds
/// 4. **Federation** — SAML, OIDC, and social providers per realm
///
/// Keycloak is deployed via the Bitnami-free official Keycloak Operator or Helm chart.
/// Realms, providers, and themes are configured post-install via the Keycloak Admin API.
/// </summary>
public class KeycloakTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> keycloakInstaller;
public KeycloakTests()
{
repository = new InMemoryClusterRepository();
keycloakInstaller = new Mock<IComponentInstaller>();
keycloakInstaller.Setup(i => i.ComponentName).Returns("keycloak");
}
// ─── Keycloak Installation ────────────────────────────────────────────────
[Fact]
public async Task Keycloak_Install_DeploysWithOperatorAndDefaultRealm()
{
// Arrange — Deploy Keycloak with its operator and a default master realm.
// The operator manages Keycloak instances via CRDs (Keycloak, KeycloakRealm).
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
keycloakInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "Keycloak 26.0 installed with operator",
Actions: new List<string>
{
"Ensured namespace 'keycloak'",
"Helm install keycloak-operator v26.0.0",
"Created Keycloak CR 'platform-keycloak'",
"Keycloak available at auth.internal.corp.com",
"BankID provider plugin deployed"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
keycloakInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("keycloak", Version: "26.0.0",
Parameters: new Dictionary<string, string>
{
["keycloakDomain"] = "auth.internal.corp.com",
["adminPassword"] = "admin123",
["storageClass"] = "longhorn",
["databaseType"] = "postgres",
["installBankIdPlugin"] = "true"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Keycloak operator and instance deployed.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("keycloak-operator"));
result.Value.Actions.Should().Contain(a => a.Contains("Keycloak CR"));
result.Value.Actions.Should().Contain(a => a.Contains("BankID"));
keycloakInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["keycloakDomain"] == "auth.internal.corp.com" &&
o.Parameters["installBankIdPlugin"] == "true"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Keycloak_Install_WithoutDatabase_ReportsFailure()
{
// Arrange — Keycloak requires a database (internal or CloudNativePG).
// If no database is available, the install should fail gracefully.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
keycloakInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: false,
ComponentName: "keycloak",
Message: "Keycloak requires a PostgreSQL database (CloudNativePG or external)",
Actions: new List<string> { "Prerequisite check failed: no database available" }));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
keycloakInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("keycloak",
Parameters: new Dictionary<string, string>
{
["keycloakDomain"] = "auth.internal.corp.com"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("database");
}
// ─── Realm Management ─────────────────────────────────────────────────────
[Fact]
public async Task Keycloak_Configure_CreateRealm_WithCustomTheme()
{
// Arrange — Create a new realm with a custom name, logo, background
// image, and brand colors. The realm is fully isolated and can have
// its own identity providers, users, and theme.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
keycloakInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "Realm 'tenant-alpha' created with custom theme",
Actions: new List<string>
{
"Created realm 'tenant-alpha'",
"Set realm display name to 'Alpha Corp'",
"Deployed custom theme 'alpha-theme'",
"Theme: logo uploaded, primary color #1A73E8, background set",
"Realm login page configured with custom branding"
}));
List<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["createRealm"] = "tenant-alpha",
["realmDisplayName"] = "Alpha Corp",
["themeLogo"] = "https://cdn.alpha.com/logo.png",
["themeBackground"] = "https://cdn.alpha.com/bg.jpg",
["themePrimaryColor"] = "#1A73E8",
["themeSecondaryColor"] = "#FFFFFF",
["themeLoginTitle"] = "Welcome to Alpha Corp"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — Realm created with full theming.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("realm 'tenant-alpha'"));
result.Value.Actions.Should().Contain(a => a.Contains("Alpha Corp"));
result.Value.Actions.Should().Contain(a => a.Contains("theme"));
result.Value.Actions.Should().Contain(a => a.Contains("#1A73E8"));
}
[Fact]
public async Task Keycloak_Configure_AddBankIdProvider_ToRealm()
{
// Arrange — Add a Swedish BankID identity provider to a specific realm.
// BankID requires a service certificate (P12) and API endpoint configuration.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
keycloakInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "BankID provider added to realm 'tenant-alpha'",
Actions: new List<string>
{
"Configured BankID identity provider in realm 'tenant-alpha'",
"Stored BankID service certificate in Keycloak keystore",
"BankID endpoint: https://appapi2.bankid.com/rp/v6.0",
"BankID provider enabled as login option"
}));
List<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["realmName"] = "tenant-alpha",
["addProvider"] = "bankid",
["bankIdEnvironment"] = "production",
["bankIdCertificateP12"] = "MIIEvgIBADANBg...", // base64 P12
["bankIdCertificatePassword"] = "cert-password",
["bankIdDisplayName"] = "Logga in med BankID"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — BankID configured as identity provider.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("BankID") && a.Contains("tenant-alpha"));
result.Value.Actions.Should().Contain(a => a.Contains("certificate"));
result.Value.Actions.Should().Contain(a => a.Contains("appapi2.bankid.com"));
}
[Fact]
public async Task Keycloak_Configure_AddOIDCProvider_ToRealm()
{
// Arrange — Add a generic OIDC identity provider (e.g., Azure AD, Google)
// to a realm, independently from other realms.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
keycloakInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "OIDC provider 'azure-ad' added to realm 'tenant-beta'",
Actions: new List<string>
{
"Configured OIDC identity provider 'azure-ad' in realm 'tenant-beta'",
"Discovery URL: https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration",
"Client ID configured",
"Provider enabled as login option"
}));
List<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["realmName"] = "tenant-beta",
["addProvider"] = "oidc",
["providerAlias"] = "azure-ad",
["providerDisplayName"] = "Sign in with Microsoft",
["oidcDiscoveryUrl"] = "https://login.microsoftonline.com/abc123/.well-known/openid-configuration",
["oidcClientId"] = "client-id-here",
["oidcClientSecret"] = "client-secret-here"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — OIDC provider configured independently in tenant-beta.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("azure-ad") && a.Contains("tenant-beta"));
result.Value.Actions.Should().Contain(a => a.Contains("Discovery URL"));
}
[Fact]
public async Task Keycloak_Configure_AddSAMLProvider_ToRealm()
{
// Arrange — Add a SAML identity provider to a realm. Useful for
// enterprise customers with ADFS or other SAML-based IdPs.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
keycloakInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "SAML provider 'corp-adfs' added to realm 'tenant-gamma'",
Actions: new List<string>
{
"Configured SAML identity provider 'corp-adfs' in realm 'tenant-gamma'",
"SAML entity ID: https://adfs.corp.com/adfs/services/trust",
"SSO URL configured",
"Provider enabled as login option"
}));
List<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["realmName"] = "tenant-gamma",
["addProvider"] = "saml",
["providerAlias"] = "corp-adfs",
["providerDisplayName"] = "Enterprise SSO",
["samlEntityId"] = "https://adfs.corp.com/adfs/services/trust",
["samlSsoUrl"] = "https://adfs.corp.com/adfs/ls/",
["samlCertificate"] = "MIIDXTCCAkWgAwIBAgI..."
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — SAML provider configured.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("SAML") && a.Contains("tenant-gamma"));
result.Value.Actions.Should().Contain(a => a.Contains("entity ID"));
}
[Fact]
public async Task Keycloak_Configure_UpdateRealmTheme()
{
// Arrange — Update the theme of an existing realm. This changes logo,
// colors, and background without recreating the realm.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
keycloakInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "Theme updated for realm 'tenant-alpha'",
Actions: new List<string>
{
"Updated theme for realm 'tenant-alpha'",
"Logo updated to new URL",
"Primary color changed to #FF5722",
"Background image updated"
}));
List<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["realmName"] = "tenant-alpha",
["updateTheme"] = "true",
["themeLogo"] = "https://cdn.alpha.com/new-logo.png",
["themeBackground"] = "https://cdn.alpha.com/new-bg.jpg",
["themePrimaryColor"] = "#FF5722"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — Theme updated without affecting realm config.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("Updated theme"));
result.Value.Actions.Should().Contain(a => a.Contains("#FF5722"));
}
[Fact]
public async Task Keycloak_Configure_MultipleProviders_DifferentRealms()
{
// Arrange — Verify that providers are realm-scoped. Adding BankID to
// tenant-alpha does NOT affect tenant-beta which has its own OIDC provider.
// This test verifies the realm isolation.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
keycloakInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((KubernetesCluster c, ComponentConfiguration cfg, CancellationToken _) =>
{
string realm = cfg.Values["realmName"];
string provider = cfg.Values["addProvider"];
return new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: $"Provider '{provider}' added to realm '{realm}'",
Actions: new List<string>
{
$"Configured {provider} provider in realm '{realm}' only",
$"Other realms not affected"
});
});
List<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
// Act — Add BankID to tenant-alpha.
ConfigureComponentRequest request1 = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["realmName"] = "tenant-alpha",
["addProvider"] = "bankid",
["bankIdEnvironment"] = "test"
});
Result<InstallResult> result1 = await configHandler.HandleAsync(cluster.Id, request1);
// Act — Add OIDC to tenant-beta.
ConfigureComponentRequest request2 = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["realmName"] = "tenant-beta",
["addProvider"] = "oidc",
["providerAlias"] = "google",
["oidcDiscoveryUrl"] = "https://accounts.google.com/.well-known/openid-configuration",
["oidcClientId"] = "google-client-id",
["oidcClientSecret"] = "google-secret"
});
Result<InstallResult> result2 = await configHandler.HandleAsync(cluster.Id, request2);
// Assert — Each realm got its own provider independently.
result1.IsSuccess.Should().BeTrue();
result1.Value!.Actions.Should().Contain(a => a.Contains("tenant-alpha") && a.Contains("only"));
result2.IsSuccess.Should().BeTrue();
result2.Value!.Actions.Should().Contain(a => a.Contains("tenant-beta") && a.Contains("only"));
}
// ─── Keycloak Adoption Check ──────────────────────────────────────────────
[Fact]
public async Task Keycloak_Check_DetectsExistingDeployment()
{
// Arrange — Keycloak is already running. The check discovers the
// instance, its realms, and configured providers.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IAdoptionCheck> keycloakCheck = new();
keycloakCheck.Setup(c => c.ComponentName).Returns("keycloak");
keycloakCheck.Setup(c => c.DisplayName).Returns("Keycloak (Identity & Access Management)");
keycloakCheck
.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult(
"keycloak",
ComponentStatus.Installed,
Details: new List<string>
{
"Keycloak 26.0 detected (operator in namespace 'keycloak')",
"Endpoint: auth.internal.corp.com",
"Realms: master, tenant-alpha, tenant-beta",
"tenant-alpha: BankID provider enabled, custom theme",
"tenant-beta: OIDC (Azure AD) provider enabled",
"BankID plugin version 1.2.0 installed"
},
MissingItems: new List<string>(),
Configuration: new DiscoveredConfiguration(
Version: "26.0.0",
Namespace: "keycloak",
HelmReleaseName: "keycloak-operator",
Values: new Dictionary<string, string>
{
["endpoint"] = "auth.internal.corp.com",
["realms"] = "master,tenant-alpha,tenant-beta",
["bankIdPluginVersion"] = "1.2.0",
["databaseType"] = "postgres"
})));
// Act
ComponentCheckResult checkResult = await keycloakCheck.Object.CheckAsync(cluster);
// Assert — Full Keycloak state discovered.
checkResult.Status.Should().Be(ComponentStatus.Installed);
checkResult.Configuration.Should().NotBeNull();
checkResult.Configuration!.Version.Should().Be("26.0.0");
checkResult.Configuration.Values["realms"].Should().Contain("tenant-alpha");
checkResult.Details.Should().Contain(d => d.Contains("BankID"));
checkResult.Details.Should().Contain(d => d.Contains("Azure AD"));
}
private static KubernetesCluster CreateConnectedCluster()
{
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443",
"apiVersion: v1\nkind: Config\nclusters: []\nusers: []\ncontexts: []",
Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
return cluster;
}
// ─── CNPG Database Integration ────────────────────────────────────────────
[Fact]
public void KeycloakSchema_ContainsCnpgClusterNameParameter()
{
// Keycloak should reference the shared CNPG cluster for its database
// instead of assuming a dedicated keycloak-db cluster exists.
ConfigurationSchema schema = ComponentSchemas.GetSchema("keycloak");
schema.Parameters.Should().Contain(p =>
p.Key == "cnpgClusterName" && p.Type == ParameterType.PostgresCluster);
}
[Fact]
public void KeycloakSchema_ContainsCnpgNamespaceParameter()
{
// The CNPG cluster namespace — needed to resolve the database endpoint.
ConfigurationSchema schema = ComponentSchemas.GetSchema("keycloak");
schema.Parameters.Should().Contain(p => p.Key == "cnpgNamespace");
}
}

View File

@@ -0,0 +1,176 @@
using System.Text.Json;
using EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies;
using EntKube.Clusters.Features.ClusterSettings;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests the JSON parsing logic that extracts allowed registries from a Kyverno
/// ClusterPolicy. This is the core logic that reads the restrict-image-registries
/// policy and returns the list of approved registries.
/// </summary>
public class KyvernoClusterSettingsProviderTests
{
[Fact]
public void ExtractRegistries_WithValidPolicy_ReturnsRegistryList()
{
// Arrange — a policy JSON with three allowed registries.
string json = """
{
"spec": {
"validationFailureAction": "Audit",
"rules": [{
"name": "validate-image-registry",
"validate": {
"foreach": [{
"list": "request.object.spec.[initContainers, containers][]",
"deny": {
"conditions": {
"all": [
{ "key": "{{ element.image }}", "operator": "NotEquals", "value": "docker.io/*" },
{ "key": "{{ element.image }}", "operator": "NotEquals", "value": "ghcr.io/*" },
{ "key": "{{ element.image }}", "operator": "NotEquals", "value": "registry.k8s.io/*" }
]
}
}
}]
}
}]
}
}
""";
// Act
List<string> registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson(json);
// Assert
registries.Should().BeEquivalentTo(new[] { "docker.io", "ghcr.io", "registry.k8s.io" });
}
[Fact]
public void ExtractRegistries_WithEmptyJson_ReturnsEmptyList()
{
// Act
List<string> registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson("{}");
// Assert
registries.Should().BeEmpty();
}
[Fact]
public void ExtractRegistries_WithMalformedJson_ReturnsEmptyList()
{
// Act
List<string> registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson("not json");
// Assert
registries.Should().BeEmpty();
}
[Fact]
public void ExtractRegistries_WithNoPolicyRules_ReturnsEmptyList()
{
// Arrange
string json = """{ "spec": { "rules": [] } }""";
// Act
List<string> registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson(json);
// Assert
registries.Should().BeEmpty();
}
// ─── Round-trip tests ──────────────────────────────────────────────────
[Fact]
public void BuildRegistryPolicy_RoundTrip_PreservesRegistries()
{
// When the settings provider writes a policy and then reads it back,
// the extracted registries should match what was written. This simulates
// the full Kubernetes API round-trip: build → serialize → parse.
// Arrange — a custom list of registries.
List<string> registries = new() { "docker.io", "ghcr.io", "myregistry.example.com" };
// Act — build the policy, serialize it to JSON (simulating K8s storage),
// then extract registries back out.
object policy = KyvernoClusterSettingsProvider.BuildRegistryPolicy(registries, "test message", "Audit");
string json = JsonSerializer.Serialize(policy);
List<string> extracted = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson(json);
// Assert — the registries should survive the round-trip intact.
extracted.Should().BeEquivalentTo(registries);
}
[Fact]
public void BuildRegistryPolicy_IncludesExcludeBlock()
{
// The policy must include the exclude block so system namespaces
// (kube-system, cert-manager, etc.) are not subject to registry
// restrictions. Without this, saving registries via the Settings tab
// would create a policy that differs from the installer's policy and
// could break system pods.
// Arrange
List<string> registries = new() { "docker.io" };
// Act
object policy = KyvernoClusterSettingsProvider.BuildRegistryPolicy(registries, "test", "Audit");
string json = JsonSerializer.Serialize(policy);
using JsonDocument doc = JsonDocument.Parse(json);
// Assert — the rule should have an "exclude" block with namespace exclusions.
JsonElement rule = doc.RootElement
.GetProperty("spec")
.GetProperty("rules")[0];
rule.TryGetProperty("exclude", out JsonElement exclude).Should().BeTrue(
"the policy rule must include an exclude block to protect system namespaces");
string excludeJson = exclude.GetRawText();
excludeJson.Should().Contain("kube-system");
}
[Fact]
public void BuildRegistryPolicy_IncludesManagedByLabels()
{
// The policy labels must match what the SecurityPoliciesInstaller creates
// so the installer can find and replace the policy during reconfiguration.
// Arrange
List<string> registries = new() { "docker.io" };
// Act
object policy = KyvernoClusterSettingsProvider.BuildRegistryPolicy(registries, "test", "Enforce");
string json = JsonSerializer.Serialize(policy);
using JsonDocument doc = JsonDocument.Parse(json);
// Assert — should have both managed-by and part-of labels.
JsonElement labels = doc.RootElement
.GetProperty("metadata")
.GetProperty("labels");
labels.GetProperty("app.kubernetes.io/managed-by").GetString().Should().Be("entkube");
labels.GetProperty("app.kubernetes.io/part-of").GetString().Should().Be("security-policies");
}
}

View File

@@ -0,0 +1,748 @@
using System.Text.Json;
using EntKube.Clusters.Features.AdoptCluster.Components.LetsEncrypt;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the LetsEncrypt component check — specifically the DNS01 solver
/// configuration extraction logic. When a cluster has ACME ClusterIssuers using
/// DNS01 challenges, the check should extract the provider type, secret references,
/// hosted zones, and domain selectors so the UI can display them properly.
/// </summary>
public class LetsEncryptCheckTests
{
// ─── DNS01 Solver Parsing ─────────────────────────────────────────────
[Fact]
public void ParseDns01Details_WithCloudflareSolver_ExtractsProviderAndSecret()
{
// Arrange — A ClusterIssuer with a Cloudflare DNS01 solver. The solver
// specifies an API token stored in a Kubernetes Secret.
string json = """
{
"cloudflare": {
"apiTokenSecretRef": {
"name": "cloudflare-api-token",
"key": "api-token"
}
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act — Extract DNS01 details from the solver configuration.
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert — Should identify Cloudflare as the provider and capture the secret name.
details.Provider.Should().Be("cloudflare");
details.SecretName.Should().Be("cloudflare-api-token");
details.HostedZone.Should().BeNull();
details.Project.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithRoute53Solver_ExtractsProviderAndHostedZone()
{
// Arrange — A ClusterIssuer with an AWS Route53 DNS01 solver. Route53
// solvers include a hosted zone ID and region.
string json = """
{
"route53": {
"region": "eu-north-1",
"hostedZoneID": "Z1234567890ABC"
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert
details.Provider.Should().Be("route53");
details.HostedZone.Should().Be("Z1234567890ABC");
details.SecretName.Should().BeNull();
details.Project.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithRoute53AndSecretAccessKey_ExtractsSecretName()
{
// Arrange — A Route53 DNS01 solver using explicit credentials stored in
// a Kubernetes Secret via secretAccessKeySecretRef. This is the most common
// setup for non-IRSA (IAM Roles for Service Accounts) Route53 configurations.
string json = """
{
"route53": {
"region": "eu-north-1",
"hostedZoneID": "Z1234567890ABC",
"accessKeyID": "AKIAIOSFODNN7EXAMPLE",
"secretAccessKeySecretRef": {
"name": "aws-route53-credentials",
"key": "secret-access-key"
}
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert — Should capture both the hosted zone and the credential secret.
details.Provider.Should().Be("route53");
details.HostedZone.Should().Be("Z1234567890ABC");
details.SecretName.Should().Be("aws-route53-credentials");
details.Project.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithAzureDnsServicePrincipal_ExtractsAllFields()
{
// Arrange — An Azure DNS solver using a service principal for authentication.
// This is the most common Azure DNS setup: the ClusterIssuer authenticates
// to Azure using a clientID + clientSecret (stored in a K8s Secret),
// scoped to a specific subscription, tenant, resource group, and hosted zone.
string json = """
{
"azureDNS": {
"clientID": "app-id-12345",
"clientSecretSecretRef": {
"name": "azuredns-sp-secret",
"key": "client-secret"
},
"subscriptionID": "sub-aaaa-bbbb-cccc",
"tenantID": "tenant-xxxx-yyyy",
"resourceGroupName": "dns-rg",
"hostedZoneName": "example.com",
"environment": "AzurePublicCloud"
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert — All Azure DNS fields should be extracted.
details.Provider.Should().Be("azuredns");
details.HostedZone.Should().Be("example.com");
details.SecretName.Should().Be("azuredns-sp-secret");
details.ClientId.Should().Be("app-id-12345");
details.SubscriptionId.Should().Be("sub-aaaa-bbbb-cccc");
details.TenantId.Should().Be("tenant-xxxx-yyyy");
details.ResourceGroup.Should().Be("dns-rg");
details.Project.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithAzureDnsManagedIdentity_ExtractsHostedZone()
{
// Arrange — An Azure DNS solver using managed identity (no clientSecret).
// Only the hosted zone and resource group are present.
string json = """
{
"azureDNS": {
"hostedZoneName": "example.com",
"resourceGroupName": "dns-rg",
"subscriptionID": "sub-123",
"environment": "AzurePublicCloud"
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert — Should capture what's available, leave missing fields null.
details.Provider.Should().Be("azuredns");
details.HostedZone.Should().Be("example.com");
details.ResourceGroup.Should().Be("dns-rg");
details.SubscriptionId.Should().Be("sub-123");
details.SecretName.Should().BeNull();
details.ClientId.Should().BeNull();
details.TenantId.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithCloudDnsSolver_ExtractsProviderAndProject()
{
// Arrange — A Google Cloud DNS solver with a project and service account.
string json = """
{
"cloudDNS": {
"project": "my-gcp-project",
"serviceAccountSecretRef": {
"name": "clouddns-service-account",
"key": "key.json"
}
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert
details.Provider.Should().Be("clouddns");
details.Project.Should().Be("my-gcp-project");
details.SecretName.Should().Be("clouddns-service-account");
details.HostedZone.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithEmptyDns01_ReturnsUnknownProvider()
{
// Arrange — A DNS01 solver with no recognized provider (unusual but possible
// with custom webhook solvers).
string json = """
{
"webhook": {
"solverName": "custom-solver"
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert — Should report unknown when no recognized provider is found.
details.Provider.Should().BeNull();
details.SecretName.Should().BeNull();
}
// ─── Domain Selector Parsing ──────────────────────────────────────────
[Fact]
public void ParseDnsZones_WithDnsZonesSelector_ExtractsZones()
{
// Arrange — A solver with a selector that scopes it to specific DNS zones.
// This is common when using DNS01 for wildcard certificates on specific domains.
string json = """
{
"selector": {
"dnsZones": ["example.com", "internal.example.com"]
},
"dns01": {
"cloudflare": {
"apiTokenSecretRef": {
"name": "cf-token",
"key": "api-token"
}
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
List<string> zones = LetsEncryptCheck.ParseDnsZones(solverElement);
// Assert
zones.Should().BeEquivalentTo(new[] { "example.com", "internal.example.com" });
}
[Fact]
public void ParseDnsZones_WithNoSelector_ReturnsEmptyList()
{
// Arrange — A solver with no selector (applies to all domains).
string json = """
{
"dns01": {
"cloudflare": {
"apiTokenSecretRef": {
"name": "cf-token",
"key": "api-token"
}
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
List<string> zones = LetsEncryptCheck.ParseDnsZones(solverElement);
// Assert
zones.Should().BeEmpty();
}
// ─── Per-Issuer Config Values ─────────────────────────────────────────
[Fact]
public void BuildConfigValues_WithMultipleIssuers_IncludesPerIssuerDetails()
{
// Arrange — Two ACME issuers: one staging with HTTP01, one production
// with DNS01 using Cloudflare. The config values should include both
// global summary and per-issuer detail so the UI can display each issuer.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-staging",
Email: "admin@example.com",
AcmeServer: "https://acme-staging-v02.api.letsencrypt.org/directory",
SolverType: "HTTP01",
IsReady: true,
Dns01Details: null,
DnsZones: new List<string>()),
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "admin@example.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "DNS01",
IsReady: true,
Dns01Details: new Dns01Details(
Provider: "cloudflare",
SecretName: "cloudflare-api-token",
HostedZone: null,
Project: null),
DnsZones: new List<string> { "example.com" })
};
// Act — Build the configuration values dictionary from the detected issuers.
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert — Global summary values.
configValues["issuers"].Should().Be("letsencrypt-staging,letsencrypt-prod");
configValues["issuerCount"].Should().Be("2");
configValues["email"].Should().Be("admin@example.com");
configValues["readyCount"].Should().Be("2");
configValues["httpSolverEnabled"].Should().Be("true");
configValues["dnsSolverEnabled"].Should().Be("true");
// Assert — Per-issuer detail for the DNS01 issuer.
configValues["issuer.letsencrypt-prod.solverType"].Should().Be("DNS01");
configValues["issuer.letsencrypt-prod.acmeServer"].Should().Be("https://acme-v02.api.letsencrypt.org/directory");
configValues["issuer.letsencrypt-prod.dnsSolverProvider"].Should().Be("cloudflare");
configValues["issuer.letsencrypt-prod.dnsSolverSecretName"].Should().Be("cloudflare-api-token");
configValues["issuer.letsencrypt-prod.dnsZones"].Should().Be("example.com");
// Assert — Per-issuer detail for the HTTP01 issuer.
configValues["issuer.letsencrypt-staging.solverType"].Should().Be("HTTP01");
configValues["issuer.letsencrypt-staging.acmeServer"].Should().Be("https://acme-staging-v02.api.letsencrypt.org/directory");
}
[Fact]
public void BuildConfigValues_WithDns01Solver_PopulatesGlobalDnsSolverFields()
{
// Arrange — A single issuer with DNS01. The global config values should
// include the DNS solver details so the "Current Configuration" view
// shows them without needing to expand per-issuer details.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-production",
Email: "certs@company.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "DNS01",
IsReady: true,
Dns01Details: new Dns01Details(
Provider: "route53",
SecretName: null,
HostedZone: "Z1234567890ABC",
Project: null),
DnsZones: new List<string> { "company.com", "internal.company.com" })
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert — Global DNS solver values populated from the first DNS01 issuer found.
configValues["httpSolverEnabled"].Should().Be("false");
configValues["dnsSolverEnabled"].Should().Be("true");
configValues["dnsSolverProvider"].Should().Be("route53");
configValues["dnsSolverHostedZone"].Should().Be("Z1234567890ABC");
configValues["dnsZones"].Should().Be("company.com,internal.company.com");
configValues.Should().NotContainKey("dnsSolverProject");
}
[Fact]
public void BuildConfigValues_WithCloudDnsSolver_IncludesProject()
{
// Arrange — A GCP Cloud DNS solver should include the project.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "ops@company.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "DNS01",
IsReady: true,
Dns01Details: new Dns01Details(
Provider: "clouddns",
SecretName: "gcp-dns-sa",
HostedZone: null,
Project: "my-gcp-project"),
DnsZones: new List<string>())
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert
configValues["dnsSolverProvider"].Should().Be("clouddns");
configValues["dnsSolverProject"].Should().Be("my-gcp-project");
configValues["dnsSolverSecretName"].Should().Be("gcp-dns-sa");
configValues["dnsSolverSecretNameGcp"].Should().Be("gcp-dns-sa");
}
[Fact]
public void BuildConfigValues_WithHttp01Only_DoesNotIncludeDnsFields()
{
// Arrange — An HTTP01-only issuer should not produce DNS solver fields.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "admin@example.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "HTTP01",
IsReady: true,
Dns01Details: null,
DnsZones: new List<string>())
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert
configValues["httpSolverEnabled"].Should().Be("true");
configValues["dnsSolverEnabled"].Should().Be("false");
configValues.Should().NotContainKey("dnsSolverProvider");
configValues.Should().NotContainKey("dnsSolverSecretName");
configValues.Should().NotContainKey("dnsSolverHostedZone");
configValues.Should().NotContainKey("dnsSolverProject");
configValues.Should().NotContainKey("dnsSolverClientId");
configValues.Should().NotContainKey("dnsSolverSubscriptionId");
configValues.Should().NotContainKey("dnsSolverTenantId");
configValues.Should().NotContainKey("dnsSolverResourceGroup");
configValues.Should().NotContainKey("dnsZones");
}
[Fact]
public void BuildConfigValues_WithAzureDnsServicePrincipal_IncludesAllAzureFields()
{
// Arrange — An Azure DNS solver with full service principal configuration.
// All Azure-specific fields should appear in both global and per-issuer config.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "certs@company.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "DNS01",
IsReady: true,
Dns01Details: new Dns01Details(
Provider: "azuredns",
SecretName: "azuredns-sp-secret",
HostedZone: "company.com",
Project: null,
ClientId: "app-id-12345",
SubscriptionId: "sub-aaaa-bbbb",
TenantId: "tenant-xxxx",
ResourceGroup: "dns-rg"),
DnsZones: new List<string>())
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert — Global DNS solver values include all Azure fields.
configValues["dnsSolverProvider"].Should().Be("azuredns");
configValues["dnsSolverSecretName"].Should().Be("azuredns-sp-secret");
configValues["dnsSolverSecretNameAzure"].Should().Be("azuredns-sp-secret");
configValues["dnsSolverHostedZone"].Should().Be("company.com");
configValues["dnsSolverClientId"].Should().Be("app-id-12345");
configValues["dnsSolverSubscriptionId"].Should().Be("sub-aaaa-bbbb");
configValues["dnsSolverTenantId"].Should().Be("tenant-xxxx");
configValues["dnsSolverResourceGroup"].Should().Be("dns-rg");
// Assert — Per-issuer values also include Azure fields.
configValues["issuer.letsencrypt-prod.dnsSolverProvider"].Should().Be("azuredns");
configValues["issuer.letsencrypt-prod.dnsSolverSecretName"].Should().Be("azuredns-sp-secret");
configValues["issuer.letsencrypt-prod.dnsSolverClientId"].Should().Be("app-id-12345");
configValues["issuer.letsencrypt-prod.dnsSolverSubscriptionId"].Should().Be("sub-aaaa-bbbb");
configValues["issuer.letsencrypt-prod.dnsSolverTenantId"].Should().Be("tenant-xxxx");
configValues["issuer.letsencrypt-prod.dnsSolverResourceGroup"].Should().Be("dns-rg");
}
// ─── HTTP01 Solver Mode Detection ─────────────────────────────────────
[Fact]
public void BuildConfigValues_WithGatewayApiHttp01_ReportsGatewayMode()
{
// Arrange — An issuer using HTTP01 via gatewayHTTPRoute (Gateway API)
// instead of traditional Ingress. The config should report the mode
// so the UI can show Gateway API-specific fields.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "admin@example.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "HTTP01",
IsReady: true,
Dns01Details: null,
DnsZones: new List<string>(),
Http01Mode: "gatewayHTTPRoute",
Http01GatewayName: "internal",
Http01GatewayNamespace: "internal-ingress")
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert — Global config should reflect gateway mode.
configValues["httpSolverEnabled"].Should().Be("true");
configValues["httpSolverMode"].Should().Be("gatewayHTTPRoute");
configValues["httpSolverGatewayName"].Should().Be("internal");
configValues["httpSolverGatewayNamespace"].Should().Be("internal-ingress");
configValues.Should().NotContainKey("httpSolverIngressClass");
}
[Fact]
public void BuildConfigValues_WithIngressHttp01_ReportsIngressMode()
{
// Arrange — A traditional ingress-based HTTP01 solver. The config
// should report "ingress" mode with the ingress class.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "admin@example.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "HTTP01",
IsReady: true,
Dns01Details: null,
DnsZones: new List<string>(),
Http01Mode: "ingress",
Http01IngressClass: "traefik")
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert — Global config should reflect ingress mode.
configValues["httpSolverEnabled"].Should().Be("true");
configValues["httpSolverMode"].Should().Be("ingress");
configValues["httpSolverIngressClass"].Should().Be("traefik");
configValues.Should().NotContainKey("httpSolverGatewayName");
}
// ─── HTTP01 Solver Parsing ────────────────────────────────────────────
[Fact]
public void ParseHttp01Details_WithGatewayHTTPRoute_ExtractsParentRef()
{
// Arrange — An HTTP01 solver configured with gatewayHTTPRoute (for
// clusters using Gateway API instead of Ingress). The solver specifies
// which Gateway to attach the challenge HTTPRoute to.
string json = """
{
"http01": {
"gatewayHTTPRoute": {
"parentRefs": [
{
"name": "internal",
"namespace": "internal-ingress",
"kind": "Gateway"
}
]
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement);
// Assert — Should identify gateway mode and extract parent ref.
details.Mode.Should().Be("gatewayHTTPRoute");
details.GatewayName.Should().Be("internal");
details.GatewayNamespace.Should().Be("internal-ingress");
details.IngressClass.Should().BeNull();
}
[Fact]
public void ParseHttp01Details_WithIngress_ExtractsIngressClass()
{
// Arrange — A traditional ingress-based HTTP01 solver.
string json = """
{
"http01": {
"ingress": {
"ingressClassName": "traefik"
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement);
// Assert
details.Mode.Should().Be("ingress");
details.IngressClass.Should().Be("traefik");
details.GatewayName.Should().BeNull();
details.GatewayNamespace.Should().BeNull();
}
[Fact]
public void ParseHttp01Details_WithIngressClassField_ExtractsClass()
{
// Arrange — Some older issuers use "class" instead of "ingressClassName".
string json = """
{
"http01": {
"ingress": {
"class": "nginx"
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement);
// Assert
details.Mode.Should().Be("ingress");
details.IngressClass.Should().Be("nginx");
}
[Fact]
public void ParseHttp01Details_WithNoHttp01_ReturnsNull()
{
// Arrange — A solver that only has dns01.
string json = """
{
"dns01": {
"cloudflare": {}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
Http01Details? details = LetsEncryptCheck.ParseHttp01Details(solverElement);
// Assert
details.Should().BeNull();
}
[Fact]
public void ParseHttp01Details_WithGatewayHTTPRouteNoNamespace_OmitsNamespace()
{
// Arrange — A gatewayHTTPRoute solver where the parentRef doesn't
// specify a namespace (uses the issuer's namespace by default).
string json = """
{
"http01": {
"gatewayHTTPRoute": {
"parentRefs": [
{
"name": "default-gateway"
}
]
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement);
// Assert
details.Mode.Should().Be("gatewayHTTPRoute");
details.GatewayName.Should().Be("default-gateway");
details.GatewayNamespace.Should().BeNull();
}
}

View File

@@ -20,15 +20,18 @@ public class RegisterClusterHandlerTests
[Fact]
public async Task HandleAsync_WithValidRequest_ReturnsSuccessWithClusterId()
{
// Arrange — A valid registration request with all required fields.
// Arrange — A valid registration request with all required fields
// including the tenant and kubeconfig.
RegisterClusterRequest request = new("production-eu", "https://k8s.example.com:6443", "secret-ref");
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
RegisterClusterRequest request = new("production-eu", "https://k8s.example.com:6443", "apiVersion: v1\nkind: Config", tenantId, "prod-context", environmentId);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — The handler should succeed and the cluster should be persisted.
// Assert — The handler should succeed and the cluster should be persisted with tenant link.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
@@ -36,6 +39,9 @@ public class RegisterClusterHandlerTests
KubernetesCluster? persisted = await repository.GetByIdAsync(result.Value!);
persisted.Should().NotBeNull();
persisted!.Name.Should().Be("production-eu");
persisted.TenantId.Should().Be(tenantId);
persisted.KubeConfig.Should().Contain("apiVersion");
persisted.ContextName.Should().Be("prod-context");
}
[Fact]
@@ -43,7 +49,7 @@ public class RegisterClusterHandlerTests
{
// Arrange — Missing cluster name.
RegisterClusterRequest request = new("", "https://k8s.example.com:6443", null);
RegisterClusterRequest request = new("", "https://k8s.example.com:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Act
@@ -60,7 +66,7 @@ public class RegisterClusterHandlerTests
{
// Arrange — Missing API server URL.
RegisterClusterRequest request = new("my-cluster", "", null);
RegisterClusterRequest request = new("my-cluster", "", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Act
@@ -71,4 +77,56 @@ public class RegisterClusterHandlerTests
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("API server URL");
}
[Fact]
public async Task HandleAsync_WithEmptyKubeConfig_ReturnsFailure()
{
// Arrange — Missing kubeconfig content. A cluster cannot be registered
// without credentials to access it.
RegisterClusterRequest request = new("my-cluster", "https://k8s.example.com:6443", "", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("KubeConfig");
}
[Fact]
public async Task HandleAsync_WithEmptyTenantId_ReturnsFailure()
{
// Arrange — No tenant specified. Every cluster must belong to a tenant.
RegisterClusterRequest request = new("my-cluster", "https://k8s.example.com:6443", "kubeconfig-data", Guid.Empty, "ctx", Guid.NewGuid());
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("tenant");
}
[Fact]
public async Task HandleAsync_WithEmptyEnvironmentId_ReturnsFailure()
{
// Arrange — No environment specified. Every cluster must belong to an environment.
RegisterClusterRequest request = new("my-cluster", "https://k8s.example.com:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.Empty);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("environment");
}
}

View File

@@ -0,0 +1,111 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.SetProvider;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class SetProviderHandlerTests
{
private readonly SetProviderHandler handler;
private readonly InMemoryClusterRepository repository;
public SetProviderHandlerTests()
{
repository = new InMemoryClusterRepository();
handler = new SetProviderHandler(repository);
}
[Fact]
public async Task HandleAsync_SetCleura_StoresProviderOnCluster()
{
// Arrange — A cluster is already registered with no provider.
// The user now wants to link it to their Cleura account.
KubernetesCluster cluster = KubernetesCluster.Register(
"cleura-cluster", "https://k8s.cleura.cloud", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
SetProviderRequest request = new(cluster.Id, "Cleura", "my-user", "my-password", "Sto2");
// Act
Result result = await handler.HandleAsync(request);
// Assert — The provider and credentials should be persisted on the cluster.
result.IsSuccess.Should().BeTrue();
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.Provider.Should().Be(CloudProvider.Cleura);
updated.ProviderCredentials!.Username.Should().Be("my-user");
updated.ProviderCredentials.Region.Should().Be("Sto2");
}
[Fact]
public async Task HandleAsync_SetNone_ClearsProvider()
{
// Arrange — A cluster currently linked to Cleura. The user wants to unlink it.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.SetProvider(CloudProvider.Cleura, new ProviderCredentials("u", "p", "Fra1"));
await repository.AddAsync(cluster);
SetProviderRequest request = new(cluster.Id, "None", null, null, null);
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.Provider.Should().Be(CloudProvider.None);
updated.ProviderCredentials.Should().BeNull();
}
[Fact]
public async Task HandleAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange — Non-existent cluster ID.
SetProviderRequest request = new(Guid.NewGuid(), "Cleura", "u", "p", "Sto2");
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_InvalidProvider_ReturnsFailure()
{
// Arrange — An unrecognized provider name.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
SetProviderRequest request = new(cluster.Id, "UnknownCloud", "u", "p", "eu-west-1");
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Unsupported provider");
}
}

View File

@@ -0,0 +1,89 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster.Components.Traefik;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the Traefik installer's Helm values generation. Verifies that
/// the correct cloud-provider annotations are applied when internal LB mode
/// is enabled, and that no annotations leak through when disabled.
/// </summary>
public class TraefikInstallerTests
{
[Fact]
public void GetTraefikValues_InternalLbWithCleura_IncludesOpenStackAnnotation()
{
// When Traefik is configured as an internal LB on Cleura, it should use
// the OpenStack annotation instead of dumping all provider annotations.
Dictionary<string, string> parameters = new()
{
["internalLoadBalancer"] = "true"
};
string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.Cleura);
values.Should().Contain("openstack-internal-load-balancer");
values.Should().NotContain("azure-load-balancer-internal");
}
[Fact]
public void GetTraefikValues_InternalLbWithAzure_IncludesAzureAnnotation()
{
Dictionary<string, string> parameters = new()
{
["internalLoadBalancer"] = "true"
};
string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.Azure);
values.Should().Contain("azure-load-balancer-internal");
values.Should().NotContain("openstack-internal-load-balancer");
}
[Fact]
public void GetTraefikValues_NoInternalLb_HasNoAnnotations()
{
// When internalLoadBalancer is not set, no annotations should be emitted
// regardless of the cloud provider.
Dictionary<string, string> parameters = new();
string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.Cleura);
values.Should().NotContain("annotations:");
}
[Fact]
public void GetTraefikValues_CustomReplicas_IncludesReplicaCount()
{
// When a custom replica count is specified, the generated values
// should reflect that in the deployment section.
Dictionary<string, string> parameters = new()
{
["replicas"] = "5"
};
string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.None);
values.Should().Contain("replicas: 5");
}
[Fact]
public void GetTraefikValues_DashboardEnabled_IncludesDashboardConfig()
{
// When the dashboard is enabled, the generated values should include
// dashboard configuration.
Dictionary<string, string> parameters = new()
{
["dashboardEnabled"] = "true"
};
string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.None);
values.Should().Contain("dashboard");
}
}

View File

@@ -0,0 +1,609 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
using EntKube.Clusters.Features.AdoptCluster.DeployComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the trust bundle and internal CA components. These two are co-dependent:
///
/// 1. **cert-manager** — prerequisite, already tested elsewhere
/// 2. **trust-manager** — distributes CA bundles to namespaces via Bundle CRDs
/// 3. **internal-ca** — provisions a self-signed CA ClusterIssuer via cert-manager,
/// then adds its root certificate to the trust bundle
///
/// The dependency chain: cert-manager → internal-ca → trust-manager (bundle update)
/// Both internal-ca and trust-manager require cert-manager to be installed first.
/// </summary>
public class TrustBundleAndInternalCATests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> trustManagerInstaller;
private readonly Mock<IComponentInstaller> internalCaInstaller;
public TrustBundleAndInternalCATests()
{
repository = new InMemoryClusterRepository();
trustManagerInstaller = new Mock<IComponentInstaller>();
trustManagerInstaller.Setup(i => i.ComponentName).Returns("trust-manager");
internalCaInstaller = new Mock<IComponentInstaller>();
internalCaInstaller.Setup(i => i.ComponentName).Returns("internal-ca");
}
// ─── Trust Manager Installer ──────────────────────────────────────────────
[Fact]
public async Task TrustManager_Install_DeploysHelmAndCreatesBundle()
{
// Arrange — cert-manager is already installed. Now we deploy trust-manager
// to distribute CA bundles cluster-wide.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
trustManagerInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "trust-manager",
Message: "trust-manager 0.14.0 installed with platform-trust-bundle Bundle",
Actions: new List<string>
{
"Ensured namespace 'cert-manager'",
"Helm install trust-manager v0.14.0",
"Created Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("trust-manager", Version: "0.14.0",
Parameters: new Dictionary<string, string>
{
["bundleName"] = "platform-trust-bundle",
["targetNamespaces"] = "*"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — trust-manager installed and Bundle CR created.
result.IsSuccess.Should().BeTrue();
result.Value!.ComponentName.Should().Be("trust-manager");
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
trustManagerInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o =>
o.Version == "0.14.0" &&
o.Parameters!["bundleName"] == "platform-trust-bundle"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task TrustManager_Configure_AddsCertificatesToBundle()
{
// Arrange — trust-manager is installed. We want to add extra CA certificates
// to the platform trust bundle (e.g., corporate PKI, external services).
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
trustManagerInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "trust-manager",
Message: "Trust bundle updated with 2 additional certificates",
Actions: new List<string>
{
"Added certificate 'corporate-root-ca' to bundle",
"Added certificate 'partner-api-ca' to bundle"
}));
List<IComponentInstaller> installers = new() { trustManagerInstaller.Object, internalCaInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
// Act — The user adds managed certificates to the trust bundle.
ConfigureComponentRequest configRequest = new(
ComponentName: "trust-manager",
Values: new Dictionary<string, string>
{
["additionalCertificates"] = "corporate-root-ca:LS0tLS1CRUdJTi...;partner-api-ca:LS0tLS1CRUdJTi...",
["bundleName"] = "platform-trust-bundle"
});
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("corporate-root-ca"));
}
// ─── Internal CA ──────────────────────────────────────────────────────────
[Fact]
public async Task InternalCA_Install_CreatesSelfSignedCAAndAddsToBundle()
{
// Arrange — cert-manager and trust-manager are installed. Now we create
// an internal CA that signs service certificates and whose root is
// distributed via the trust bundle.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
internalCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "internal-ca",
Message: "Internal CA provisioned and added to trust bundle",
Actions: new List<string>
{
"Created self-signed ClusterIssuer 'selfsigned-bootstrap'",
"Created root CA Certificate 'platform-internal-ca'",
"Created CA ClusterIssuer 'internal-ca'",
"Added root CA to Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("internal-ca",
Parameters: new Dictionary<string, string>
{
["caName"] = "platform-internal-ca",
["bundleName"] = "platform-trust-bundle",
["caOrganization"] = "EntKube Platform",
["caDurationDays"] = "3650"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — CA created and injected into trust bundle.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("ClusterIssuer 'internal-ca'"));
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
internalCaInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["caName"] == "platform-internal-ca" &&
o.Parameters["bundleName"] == "platform-trust-bundle"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task InternalCA_Adopt_DiscoversExistingCAAndRegistersInBundle()
{
// Arrange — A cluster already has a CA ClusterIssuer. The adoption check
// should discover it and register its root cert in the trust bundle.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
internalCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "internal-ca",
Message: "Existing internal CA adopted and added to trust bundle",
Actions: new List<string>
{
"Discovered existing CA ClusterIssuer 'existing-ca'",
"Read root CA certificate from Secret 'existing-ca-secret'",
"Added root CA to Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("internal-ca",
Parameters: new Dictionary<string, string>
{
["adoptExisting"] = "true",
["existingIssuerName"] = "existing-ca",
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Existing CA adopted and cert added to bundle.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("Discovered existing CA"));
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
}
[Fact]
public async Task InternalCA_Configure_RotatesCACertificate()
{
// Arrange — The internal CA already exists. We want to configure it
// (e.g., update the duration or rotate the CA cert).
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
internalCaInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "internal-ca",
Message: "Internal CA reconfigured",
Actions: new List<string>
{
"Updated CA Certificate duration to 7300 days",
"Refreshed root CA in Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
// Act
ComponentConfiguration config = new("cert-manager", new Dictionary<string, string>
{
["caDurationDays"] = "7300",
["bundleName"] = "platform-trust-bundle"
});
// Use the configure path through the deploy handler
// (the handler routes based on whether the component is already installed)
internalCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "internal-ca",
Message: "Internal CA reconfigured",
Actions: new List<string>
{
"Updated CA Certificate duration to 7300 days",
"Refreshed root CA in Bundle 'platform-trust-bundle'"
}));
DeployComponentRequest request = new("internal-ca",
Parameters: config.Values);
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
}
// ─── Dependency Chain ─────────────────────────────────────────────────────
[Fact]
public async Task InternalCA_WithoutCertManager_ReportsFailure()
{
// Arrange — cert-manager is NOT installed. internal-ca cannot function.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
internalCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: false,
ComponentName: "internal-ca",
Message: "cert-manager is required but not installed",
Actions: new List<string> { "Prerequisite check failed: cert-manager not found" }));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("internal-ca",
Parameters: new Dictionary<string, string>
{
["caName"] = "platform-internal-ca",
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — DeployComponentHandler wraps a failed InstallResult in Result.Failure.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("cert-manager");
}
[Fact]
public async Task TrustManager_AddManagedCertificate_UpdatesBundleWithNewCert()
{
// Arrange — trust-manager is installed. We add a new managed certificate
// (e.g., a partner's CA cert that our services need to trust).
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
trustManagerInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "trust-manager",
Message: "Certificate added to trust bundle",
Actions: new List<string>
{
"Created ConfigMap 'managed-cert-partner-api' with certificate data",
"Updated Bundle 'platform-trust-bundle' to include new source"
}));
// Use the configure handler path (component already installed)
List<IComponentInstaller> installers = new() { trustManagerInstaller.Object, internalCaInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "trust-manager",
Values: new Dictionary<string, string>
{
["addCertificate"] = "true",
["certificateName"] = "partner-api",
["certificateData"] = "LS0tLS1CRUdJTi...",
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — Certificate stored and bundle updated to reference it.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("ConfigMap"));
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
}
// ─── Domain-Scoped CA ───────────────────────────────────────────────────
[Fact]
public async Task DomainCA_Install_CreatesSeparateCAForInternalDomains()
{
// Arrange — The platform already has an internal-ca for service mesh certs.
// Now the team wants a SEPARATE CA specifically for *.internal.corp.com
// so they can issue certs for internal services on that domain.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IComponentInstaller> domainCaInstaller = new();
domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca");
domainCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "domain-ca",
Message: "Domain CA 'corp-internal-ca' provisioned for *.internal.corp.com",
Actions: new List<string>
{
"Created root CA Certificate 'corp-internal-ca'",
"Created CA ClusterIssuer 'corp-internal-ca'",
"Created Certificate policy: only signs for [*.internal.corp.com, *.svc.corp.local]",
"Added root CA to Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("domain-ca",
Parameters: new Dictionary<string, string>
{
["caName"] = "corp-internal-ca",
["domains"] = "*.internal.corp.com,*.svc.corp.local",
["caOrganization"] = "Corp Internal",
["caDurationDays"] = "1825",
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Separate domain CA created with scope restrictions.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("ClusterIssuer 'corp-internal-ca'"));
result.Value.Actions.Should().Contain(a => a.Contains("policy"));
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
domainCaInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["caName"] == "corp-internal-ca" &&
o.Parameters["domains"] == "*.internal.corp.com,*.svc.corp.local"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DomainCA_ImportExternalCA_CreatesIssuerFromProvidedCertAndKey()
{
// Arrange — The team has a public SSL cert from DigiCert for *.example.com
// and wants to use it as a CA/issuer for subdomains, or simply wants
// cert-manager to serve this cert for ingress. We import it.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IComponentInstaller> domainCaInstaller = new();
domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca");
domainCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "domain-ca",
Message: "External CA 'digicert-example-com' imported for *.example.com",
Actions: new List<string>
{
"Created TLS Secret 'digicert-example-com-tls' with imported cert+key",
"Created CA ClusterIssuer 'digicert-example-com'",
"Added CA certificate to Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("domain-ca",
Parameters: new Dictionary<string, string>
{
["caName"] = "digicert-example-com",
["importExternal"] = "true",
["tlsCert"] = "LS0tLS1CRUdJTi...", // base64 PEM cert
["tlsKey"] = "LS0tLS1CRUdJTi...", // base64 PEM key
["domains"] = "*.example.com",
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — External cert imported, ClusterIssuer created, trust bundle updated.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("TLS Secret"));
result.Value.Actions.Should().Contain(a => a.Contains("ClusterIssuer"));
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
}
[Fact]
public async Task DomainCA_ImportExternalCertOnly_AddsToBundleWithoutIssuer()
{
// Arrange — The team has an external CA root cert (no private key) that
// services need to trust (e.g., partner API CA). We just need it in the
// trust bundle — no ClusterIssuer needed since we can't sign certs with it.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IComponentInstaller> domainCaInstaller = new();
domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca");
domainCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "domain-ca",
Message: "External CA certificate 'partner-root-ca' added to trust bundle",
Actions: new List<string>
{
"Created ConfigMap 'managed-cert-partner-root-ca' with CA certificate",
"Added certificate to Bundle 'platform-trust-bundle'",
"No ClusterIssuer created (no private key provided — trust-only)"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("domain-ca",
Parameters: new Dictionary<string, string>
{
["caName"] = "partner-root-ca",
["importExternal"] = "true",
["tlsCert"] = "LS0tLS1CRUdJTi...", // cert only, no key
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Cert added to trust bundle but no issuer (can't sign without key).
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("trust-only"));
result.Value.Actions.Should().NotContain(a => a.Contains("ClusterIssuer 'partner-root-ca'") && !a.Contains("No ClusterIssuer"));
}
[Fact]
public async Task DomainCA_Configure_UpdatesDomainRestrictions()
{
// Arrange — An existing domain CA needs its allowed domains updated.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IComponentInstaller> domainCaInstaller = new();
domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca");
domainCaInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "domain-ca",
Message: "Domain CA 'corp-internal-ca' reconfigured",
Actions: new List<string>
{
"Updated Certificate policy: [*.internal.corp.com, *.new-domain.corp.com]"
}));
List<IComponentInstaller> installers = new()
{
trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object
};
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "domain-ca",
Values: new Dictionary<string, string>
{
["caName"] = "corp-internal-ca",
["domains"] = "*.internal.corp.com,*.new-domain.corp.com"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("new-domain"));
}
private static KubernetesCluster CreateConnectedCluster()
{
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443",
"apiVersion: v1\nkind: Config\nclusters: []\nusers: []\ncontexts: []",
Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
return cluster;
}
}

View File

@@ -0,0 +1,212 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.UninstallComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
public class UninstallComponentHandlerTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> kyvernoInstaller;
private readonly Mock<IComponentInstaller> monitoringInstaller;
private readonly UninstallComponentHandler handler;
public UninstallComponentHandlerTests()
{
repository = new InMemoryClusterRepository();
kyvernoInstaller = new Mock<IComponentInstaller>();
monitoringInstaller = new Mock<IComponentInstaller>();
kyvernoInstaller.Setup(i => i.ComponentName).Returns("kyverno");
monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring");
List<IComponentInstaller> installers = new() { kyvernoInstaller.Object, monitoringInstaller.Object };
handler = new UninstallComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<UninstallComponentHandler>.Instance);
}
[Fact]
public async Task HandleAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange — request an uninstall on a cluster that doesn't exist.
UninstallComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(Guid.NewGuid(), request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_ClusterNotConnected_ReturnsFailure()
{
// Arrange — Cluster is Pending, can't uninstall from it.
KubernetesCluster cluster = KubernetesCluster.Register(
"pending-cluster", "https://k8s.dev:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
UninstallComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_UnknownComponent_ReturnsFailureWithAvailable()
{
// Arrange — Requesting an uninstall for a component that has no installer.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
UninstallComponentRequest request = new("nonexistent-thing");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Should list available installers.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("kyverno");
result.Error.Should().Contain("monitoring");
}
[Fact]
public async Task HandleAsync_ValidComponent_DelegatesToInstallerUninstall()
{
// Arrange — Uninstall Kyverno from a connected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "kyverno",
Message: "Kyverno uninstalled successfully",
Actions: new List<string> { "Helm uninstall kyverno" }));
UninstallComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Should succeed and return the uninstall result.
result.IsSuccess.Should().BeTrue();
result.Value!.Success.Should().BeTrue();
result.Value.ComponentName.Should().Be("kyverno");
result.Value.Actions.Should().Contain("Helm uninstall kyverno");
}
[Fact]
public async Task HandleAsync_InstallerFails_ReturnsFailure()
{
// Arrange — The uninstall encounters an error.
KubernetesCluster cluster = KubernetesCluster.Register(
"flaky-cluster", "https://k8s.flaky:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: false,
ComponentName: "kyverno",
Message: "Helm uninstall failed: resources still in use",
Actions: new List<string> { "Error: resources still in use" }));
UninstallComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("resources still in use");
}
[Fact]
public async Task HandleAsync_PassesOptionsToInstaller()
{
// Arrange — Uninstall monitoring with a custom namespace.
KubernetesCluster cluster = KubernetesCluster.Register(
"config-cluster", "https://k8s.cfg:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ComponentInstallOptions? capturedOptions = null;
monitoringInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.Callback<KubernetesCluster, ComponentInstallOptions, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "monitoring",
Message: "Monitoring uninstalled",
Actions: new List<string>()));
UninstallComponentRequest request = new("monitoring", Namespace: "custom-monitoring");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — The handler should forward the namespace to the installer.
result.IsSuccess.Should().BeTrue();
capturedOptions.Should().NotBeNull();
capturedOptions!.Namespace.Should().Be("custom-monitoring");
}
[Fact]
public async Task HandleAsync_ComponentNameIsCaseInsensitive()
{
// Arrange — Use mixed case for the component name.
KubernetesCluster cluster = KubernetesCluster.Register(
"case-cluster", "https://k8s.case:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "kyverno",
Message: "Uninstalled",
Actions: new List<string>()));
UninstallComponentRequest request = new("Kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
}
}

View File

@@ -0,0 +1,93 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.UpdateClusterStatus;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class UpdateClusterStatusHandlerTests
{
private readonly UpdateClusterStatusHandler handler;
private readonly InMemoryClusterRepository repository;
public UpdateClusterStatusHandlerTests()
{
repository = new InMemoryClusterRepository();
handler = new UpdateClusterStatusHandler(repository);
}
[Fact]
public async Task HandleAsync_MarkConnected_UpdatesClusterStatus()
{
// Arrange — A pending cluster exists.
KubernetesCluster cluster = KubernetesCluster.Register(
"production",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"prod-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act — Mark it as connected (health check passed).
Result result = await handler.HandleAsync(
new UpdateClusterStatusRequest(cluster.Id, ClusterStatus.Connected));
// Assert
result.IsSuccess.Should().BeTrue();
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.Status.Should().Be(ClusterStatus.Connected);
updated.LastHealthCheckAt.Should().NotBeNull();
}
[Fact]
public async Task HandleAsync_MarkUnreachable_UpdatesClusterStatus()
{
// Arrange — A connected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"staging",
"https://staging-k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"staging-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
// Act — Health check fails.
Result result = await handler.HandleAsync(
new UpdateClusterStatusRequest(cluster.Id, ClusterStatus.Unreachable));
// Assert
result.IsSuccess.Should().BeTrue();
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.Status.Should().Be(ClusterStatus.Unreachable);
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Arrange
Guid unknownId = Guid.NewGuid();
// Act
Result result = await handler.HandleAsync(
new UpdateClusterStatusRequest(unknownId, ClusterStatus.Connected));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,266 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.UpgradeComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests the upgrade handler — the flow for upgrading an already-installed
/// component to a specific version. Unlike deploy, upgrade validates that
/// the component is installed and requires an explicit target version.
/// </summary>
public class UpgradeComponentHandlerTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> harborInstaller;
private readonly UpgradeComponentHandler handler;
public UpgradeComponentHandlerTests()
{
repository = new InMemoryClusterRepository();
harborInstaller = new Mock<IComponentInstaller>();
harborInstaller.Setup(i => i.ComponentName).Returns("harbor");
List<IComponentInstaller> installers = new() { harborInstaller.Object };
handler = new UpgradeComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<UpgradeComponentHandler>.Instance);
}
[Fact]
public async Task HandleAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange — Upgrading a component on a cluster that doesn't exist.
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(Guid.NewGuid(), request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_ClusterNotConnected_ReturnsFailure()
{
// Arrange — Can't upgrade components on a disconnected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"offline-cluster", "https://k8s.dev:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_UnknownComponent_ReturnsFailure()
{
// Arrange — Trying to upgrade a component that has no installer.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
UpgradeComponentRequest request = new("nonexistent", "1.0.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("No installer found");
}
[Fact]
public async Task HandleAsync_ComponentNotInstalled_ReturnsFailure()
{
// Arrange — Harbor exists as a component but is NotInstalled. You
// can't upgrade something that isn't there — use Deploy first.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> components = new()
{
new ComponentCheckResult("harbor", ComponentStatus.NotInstalled,
new List<string>(), new List<string> { "Not found" })
};
cluster.UpdateComponents(components);
await repository.AddAsync(cluster);
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not installed");
}
[Fact]
public async Task HandleAsync_ComponentNotTracked_ReturnsFailure()
{
// Arrange — The cluster has no components tracked at all. The
// component hasn't been scanned, so we don't know its state.
KubernetesCluster cluster = KubernetesCluster.Register(
"empty-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not installed");
}
[Fact]
public async Task HandleAsync_InstalledComponent_CallsInstallerAndUpdatesVersion()
{
// Arrange — Harbor is installed at 1.16.0. The operator wants to
// upgrade to 1.17.0. The handler should call the installer with
// the new version and update the tracked version on success.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> components = new()
{
new ComponentCheckResult("harbor", ComponentStatus.Installed,
new List<string> { "Running" }, new List<string>(),
new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary<string, string>()))
};
cluster.UpdateComponents(components);
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.Is<ComponentInstallOptions>(o => o.Version == "1.17.0"), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "harbor", "Harbor upgraded to 1.17.0", new List<string> { "Helm upgrade harbor v1.17.0" }));
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — The handler should succeed and the tracked version
// should be updated to reflect the upgrade.
result.IsSuccess.Should().BeTrue();
result.Value!.Success.Should().BeTrue();
result.Value.Message.Should().Contain("1.17.0");
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
ClusterComponent harbor = updated!.Components.First(c => c.ComponentName == "harbor");
harbor.Version.Should().Be("1.17.0");
}
[Fact]
public async Task HandleAsync_InstallerFails_ReturnsFailureWithoutUpdatingVersion()
{
// Arrange — The upgrade attempt fails (e.g., Helm reports an error).
// The tracked version should NOT be updated.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> components = new()
{
new ComponentCheckResult("harbor", ComponentStatus.Installed,
new List<string> { "Running" }, new List<string>(),
new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary<string, string>()))
};
cluster.UpdateComponents(components);
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(false, "harbor", "Helm upgrade failed: chart version not found", new List<string>()));
UpgradeComponentRequest request = new("harbor", "99.99.99");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Failure reported, version unchanged.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("failed");
KubernetesCluster? unchanged = await repository.GetByIdAsync(cluster.Id);
ClusterComponent harbor = unchanged!.Components.First(c => c.ComponentName == "harbor");
harbor.Version.Should().Be("1.16.0");
}
[Fact]
public async Task HandleAsync_PassesExistingNamespaceToInstaller()
{
// Arrange — When upgrading, the handler should pass the component's
// existing namespace so Helm upgrades in the right place.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> components = new()
{
new ComponentCheckResult("harbor", ComponentStatus.Installed,
new List<string>(), new List<string>(),
new DiscoveredConfiguration("1.16.0", "custom-harbor-ns", "harbor", new Dictionary<string, string>()))
};
cluster.UpdateComponents(components);
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "harbor", "Upgraded", new List<string>()));
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
await handler.HandleAsync(cluster.Id, request);
// Assert — The installer should receive the existing namespace.
harborInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o => o.Namespace == "custom-harbor-ns" && o.Version == "1.17.0"),
It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -0,0 +1,739 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies;
using FluentAssertions;
using k8s;
using k8s.Models;
using k8s.Autorest;
using Moq;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Tests.Features;
public class WorkloadRemediatorTests
{
private readonly Mock<IKubernetes> mockClient;
private readonly Mock<IAppsV1Operations> appsMock;
private readonly Mock<ILogger<WorkloadRemediator>> mockLogger;
private readonly WorkloadRemediator sut;
public WorkloadRemediatorTests()
{
mockClient = new Mock<IKubernetes>();
appsMock = new Mock<IAppsV1Operations>();
mockClient.Setup(c => c.AppsV1).Returns(appsMock.Object);
mockLogger = new Mock<ILogger<WorkloadRemediator>>();
sut = new WorkloadRemediator(mockLogger.Object);
// Default: empty workloads for all types.
SetupMockDeployments(new V1DeploymentList { Items = new List<V1Deployment>() });
SetupMockStatefulSets(new V1StatefulSetList { Items = new List<V1StatefulSet>() });
SetupMockDaemonSets(new V1DaemonSetList { Items = new List<V1DaemonSet>() });
}
// --- Seccomp Remediation ---
[Fact]
public async Task RemediateAsync_DeploymentWithoutSeccomp_PatchesSeccompProfile()
{
// Arrange — A deployment exists that has no seccomp profile set.
// The remediator should patch it to add RuntimeDefault seccomp.
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — since the mock returns no workloads, no patches needed.
// This verifies the method runs without errors on empty clusters.
result.Should().NotBeNull();
result.PatchedWorkloads.Should().NotBeNull();
result.PolicyExceptions.Should().NotBeNull();
}
[Fact]
public async Task RemediateAsync_ExcludedNamespacesAreSkipped()
{
// Arrange — Deployments in excluded namespaces should not be touched.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("kube-system", "coredns", withSeccomp: false, withReadonlyRootfs: false)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — kube-system workload should be excluded, so nothing patched.
result.PatchedWorkloads.Should().BeEmpty();
result.PolicyExceptions.Should().BeEmpty();
}
[Fact]
public async Task RemediateAsync_DeploymentMissingSeccomp_AddsSeccompPatch()
{
// Arrange — A deployment in a tenant namespace is missing seccomp.
// The remediator should patch it to add seccomp RuntimeDefault.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "my-app", withSeccomp: false, withReadonlyRootfs: true)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — The deployment was patched for seccomp compliance.
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "seccomp");
}
[Fact]
public async Task RemediateAsync_DeploymentMissingReadonlyRootfs_AddsReadonlyPatch()
{
// Arrange — A deployment missing readOnlyRootFilesystem on its containers.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "my-app", withSeccomp: true, withReadonlyRootfs: false)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "readonlyRootfs");
}
[Fact]
public async Task RemediateAsync_PatchFails_CreatesPolicyException()
{
// Arrange — A deployment that needs patching but the patch fails.
// In that case, a PolicyException should be created for that workload.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "stubborn-app", withSeccomp: false, withReadonlyRootfs: false)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchFailure();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — Since the patch failed, we get a policy exception instead.
result.PolicyExceptions.Should().Contain(e =>
e.WorkloadName == "stubborn-app" && e.Namespace == "app-namespace");
}
[Fact]
public async Task RemediateAsync_StatefulSetMissingSeccomp_PatchesIt()
{
// Arrange — StatefulSets should also be remediated.
V1StatefulSetList statefulSets = new()
{
Items = new List<V1StatefulSet>
{
CreateStatefulSet("data-namespace", "postgres", withSeccomp: false, withReadonlyRootfs: true)
}
};
SetupEmptyDeployments();
SetupMockStatefulSets(statefulSets);
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "postgres" && w.Namespace == "data-namespace" && w.Reason == "seccomp");
}
[Fact]
public async Task RemediateAsync_DaemonSetMissingReadonly_PatchesIt()
{
// Arrange — DaemonSets should also be remediated.
V1DaemonSetList daemonSets = new()
{
Items = new List<V1DaemonSet>
{
CreateDaemonSet("monitoring", "node-exporter", withSeccomp: true, withReadonlyRootfs: false)
}
};
SetupEmptyDeployments();
SetupEmptyStatefulSets();
SetupMockDaemonSets(daemonSets);
SetupPatchSuccess();
// Act — monitoring is NOT in excluded list for this test.
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "node-exporter" && w.Namespace == "monitoring" && w.Reason == "readonlyRootfs");
}
[Fact]
public async Task RemediateAsync_CompliantWorkload_NoChanges()
{
// Arrange — A workload that already has seccomp and readonlyRootfs.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "compliant-app", withSeccomp: true, withReadonlyRootfs: true)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — Compliant workload should not be touched.
result.PatchedWorkloads.Should().BeEmpty();
result.PolicyExceptions.Should().BeEmpty();
}
[Fact]
public async Task CreatePolicyExceptionAsync_CreatesValidKyvernoPolicyException()
{
// Arrange — We need to create a PolicyException for a specific workload.
PolicyExceptionRequest request = new(
WorkloadName: "my-app",
Namespace: "app-namespace",
WorkloadKind: "Deployment",
PolicyNames: new List<string> { "require-seccomp-runtime-default", "require-readonly-root-filesystem" });
// Act
object exception = WorkloadRemediator.BuildPolicyException(request);
// Assert — The resulting object should have the correct Kyverno structure.
exception.Should().NotBeNull();
Dictionary<string, object> exceptionDict = (Dictionary<string, object>)exception;
exceptionDict["apiVersion"].Should().Be("kyverno.io/v2");
exceptionDict["kind"].Should().Be("PolicyException");
}
[Fact]
public async Task RemediateAsync_WritableRootFsApp_SkipsReadonlyPatch()
{
// Arrange — A Keycloak deployment in a tenant namespace doesn't have
// readOnlyRootFilesystem set. Because Keycloak requires a writable root,
// the remediator should NOT attempt to patch it for readonly compliance.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("tenant-apps", "keycloak", withSeccomp: true, withReadonlyRootfs: false,
labels: new Dictionary<string, string> { ["app.kubernetes.io/name"] = "keycloak" })
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — No readonlyRootfs patch should be applied, and no exception needed.
result.PatchedWorkloads.Should().NotContain(w => w.Reason == "readonlyRootfs");
result.PolicyExceptions.Should().BeEmpty();
}
[Fact]
public async Task RemediateAsync_MinioStatefulSet_SkipsReadonlyPatch()
{
// Arrange — A MinIO StatefulSet needs a writable root filesystem for data
// storage. It should be skipped for readOnly remediation but still get
// seccomp patched if missing.
V1StatefulSet minioSts = new()
{
Metadata = new V1ObjectMeta { Name = "minio", NamespaceProperty = "tenant-storage" },
Spec = new V1StatefulSetSpec
{
Template = new V1PodTemplateSpec
{
Metadata = new V1ObjectMeta
{
Labels = new Dictionary<string, string> { ["app.kubernetes.io/name"] = "minio" }
},
Spec = new V1PodSpec
{
Containers = new List<V1Container>
{
new() { Name = "minio", Image = "minio/minio:latest", SecurityContext = new V1SecurityContext() }
}
}
}
}
};
SetupMockDeployments(new V1DeploymentList { Items = new List<V1Deployment>() });
SetupMockStatefulSets(new V1StatefulSetList { Items = new List<V1StatefulSet> { minioSts } });
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system" },
CancellationToken.None);
// Assert — Seccomp should be patched, but readonlyRootfs should NOT.
result.PatchedWorkloads.Should().Contain(w => w.Reason == "seccomp");
result.PatchedWorkloads.Should().NotContain(w => w.Reason == "readonlyRootfs");
}
// --- Helper Methods ---
private V1Deployment CreateDeployment(string ns, string name, bool withSeccomp, bool withReadonlyRootfs, Dictionary<string, string>? labels = null)
{
V1SecurityContext containerSecurity = new()
{
ReadOnlyRootFilesystem = withReadonlyRootfs ? true : null
};
V1PodSecurityContext podSecurity = new();
if (withSeccomp)
{
podSecurity.SeccompProfile = new V1SeccompProfile { Type = "RuntimeDefault" };
}
return new V1Deployment
{
Metadata = new V1ObjectMeta { Name = name, NamespaceProperty = ns },
Spec = new V1DeploymentSpec
{
Template = new V1PodTemplateSpec
{
Metadata = new V1ObjectMeta { Labels = labels },
Spec = new V1PodSpec
{
SecurityContext = podSecurity,
Containers = new List<V1Container>
{
new() { Name = "main", Image = "docker.io/app:latest", SecurityContext = containerSecurity }
}
}
}
}
};
}
private V1StatefulSet CreateStatefulSet(string ns, string name, bool withSeccomp, bool withReadonlyRootfs)
{
V1SecurityContext containerSecurity = new()
{
ReadOnlyRootFilesystem = withReadonlyRootfs ? true : null
};
V1PodSecurityContext podSecurity = new();
if (withSeccomp)
{
podSecurity.SeccompProfile = new V1SeccompProfile { Type = "RuntimeDefault" };
}
return new V1StatefulSet
{
Metadata = new V1ObjectMeta { Name = name, NamespaceProperty = ns },
Spec = new V1StatefulSetSpec
{
Template = new V1PodTemplateSpec
{
Spec = new V1PodSpec
{
SecurityContext = podSecurity,
Containers = new List<V1Container>
{
new() { Name = "main", Image = "docker.io/app:latest", SecurityContext = containerSecurity }
}
}
}
}
};
}
private V1DaemonSet CreateDaemonSet(string ns, string name, bool withSeccomp, bool withReadonlyRootfs)
{
V1SecurityContext containerSecurity = new()
{
ReadOnlyRootFilesystem = withReadonlyRootfs ? true : null
};
V1PodSecurityContext podSecurity = new();
if (withSeccomp)
{
podSecurity.SeccompProfile = new V1SeccompProfile { Type = "RuntimeDefault" };
}
return new V1DaemonSet
{
Metadata = new V1ObjectMeta { Name = name, NamespaceProperty = ns },
Spec = new V1DaemonSetSpec
{
Template = new V1PodTemplateSpec
{
Spec = new V1PodSpec
{
SecurityContext = podSecurity,
Containers = new List<V1Container>
{
new() { Name = "main", Image = "docker.io/app:latest", SecurityContext = containerSecurity }
}
}
}
}
};
}
private void SetupMockDeployments(V1DeploymentList deployments)
{
HttpOperationResponse<V1DeploymentList> response = new() { Body = deployments };
appsMock.Setup(a => a.ListDeploymentForAllNamespacesWithHttpMessagesAsync(
It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<int?>(), It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<bool?>(), It.IsAny<int?>(), It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
}
private void SetupEmptyDeployments()
{
SetupMockDeployments(new V1DeploymentList { Items = new List<V1Deployment>() });
}
private void SetupMockStatefulSets(V1StatefulSetList statefulSets)
{
HttpOperationResponse<V1StatefulSetList> response = new() { Body = statefulSets };
appsMock.Setup(a => a.ListStatefulSetForAllNamespacesWithHttpMessagesAsync(
It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<int?>(), It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<bool?>(), It.IsAny<int?>(), It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
}
private void SetupEmptyStatefulSets()
{
SetupMockStatefulSets(new V1StatefulSetList { Items = new List<V1StatefulSet>() });
}
private void SetupMockDaemonSets(V1DaemonSetList daemonSets)
{
HttpOperationResponse<V1DaemonSetList> response = new() { Body = daemonSets };
appsMock.Setup(a => a.ListDaemonSetForAllNamespacesWithHttpMessagesAsync(
It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<int?>(), It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<bool?>(), It.IsAny<int?>(), It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
}
private void SetupEmptyDaemonSets()
{
SetupMockDaemonSets(new V1DaemonSetList { Items = new List<V1DaemonSet>() });
}
private void SetupPatchSuccess()
{
HttpOperationResponse<V1Deployment> deployResponse = new() { Body = new V1Deployment() };
appsMock.Setup(a => a.PatchNamespacedDeploymentWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(deployResponse);
HttpOperationResponse<V1StatefulSet> stsResponse = new() { Body = new V1StatefulSet() };
appsMock.Setup(a => a.PatchNamespacedStatefulSetWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(stsResponse);
HttpOperationResponse<V1DaemonSet> dsResponse = new() { Body = new V1DaemonSet() };
appsMock.Setup(a => a.PatchNamespacedDaemonSetWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(dsResponse);
}
private void SetupPatchFailure()
{
appsMock.Setup(a => a.PatchNamespacedDeploymentWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpOperationException("Patch failed") { Response = new HttpResponseMessageWrapper(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), "") });
appsMock.Setup(a => a.PatchNamespacedStatefulSetWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpOperationException("Patch failed") { Response = new HttpResponseMessageWrapper(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), "") });
appsMock.Setup(a => a.PatchNamespacedDaemonSetWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpOperationException("Patch failed") { Response = new HttpResponseMessageWrapper(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), "") });
}
// --- Revert Patches ---
[Fact]
public async Task RevertPatchesAsync_DeploymentWithSeccomp_RemovesSeccompProfile()
{
// Arrange — A deployment that has seccomp RuntimeDefault set. The revert
// should remove the seccomp profile so the workload no longer requires it.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "my-app", withSeccomp: true, withReadonlyRootfs: false)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RevertPatchesAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — The deployment should have been reverted for seccomp.
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "revert-seccomp");
}
[Fact]
public async Task RevertPatchesAsync_DeploymentWithReadonlyRootfs_RemovesReadonly()
{
// Arrange — A deployment that has readOnlyRootFilesystem=true on its containers.
// The revert should set it back to false.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "my-app", withSeccomp: false, withReadonlyRootfs: true)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RevertPatchesAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — The deployment should have been reverted for readonlyRootfs.
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "revert-readonlyRootfs");
}
[Fact]
public async Task RevertPatchesAsync_WritableRootFsApp_SkipsReadonlyButRevertsSeccomp()
{
// Arrange — A Keycloak deployment has both seccomp and readOnlyRootFilesystem.
// Following the Terraform exclusion pattern, Keycloak is in WritableRootFsApps
// so readOnly was never applied by our remediator — we must NOT revert it.
// But seccomp WAS applied to everything, so it should still be reverted.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("tenant-apps", "keycloak", withSeccomp: true, withReadonlyRootfs: true,
labels: new Dictionary<string, string> { ["app.kubernetes.io/name"] = "keycloak" })
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RevertPatchesAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — Seccomp reverted (applied to all), readOnly NOT reverted (excluded app).
result.PatchedWorkloads.Should().Contain(w => w.Reason == "revert-seccomp");
result.PatchedWorkloads.Should().NotContain(w => w.Reason == "revert-readonlyRootfs");
}
[Fact]
public async Task RevertPatchesAsync_ExcludedNamespace_SkipsWorkloads()
{
// Arrange — Workloads in excluded namespaces should not be reverted.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("kube-system", "coredns", withSeccomp: true, withReadonlyRootfs: true)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
// Act
RemediationResult result = await sut.RevertPatchesAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — kube-system workloads are excluded, so nothing reverted.
result.PatchedWorkloads.Should().BeEmpty();
}
[Fact]
public async Task RevertPatchesAsync_CompliantAndUnpatched_NoChanges()
{
// Arrange — A workload that has neither seccomp nor readOnlyRootFilesystem
// set. Nothing to revert.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "vanilla-app", withSeccomp: false, withReadonlyRootfs: false)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
// Act
RemediationResult result = await sut.RevertPatchesAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — Nothing to revert.
result.PatchedWorkloads.Should().BeEmpty();
}
}