too much for one commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
152
tests/EntKube.Clusters.Tests/Features/CleuraClientTests.cs
Normal file
152
tests/EntKube.Clusters.Tests/Features/CleuraClientTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
183
tests/EntKube.Clusters.Tests/Features/ClusterSettingsTests.cs
Normal file
183
tests/EntKube.Clusters.Tests/Features/ClusterSettingsTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
348
tests/EntKube.Clusters.Tests/Features/GiteaComponentTests.cs
Normal file
348
tests/EntKube.Clusters.Tests/Features/GiteaComponentTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
728
tests/EntKube.Clusters.Tests/Features/HarborTests.cs
Normal file
728
tests/EntKube.Clusters.Tests/Features/HarborTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
194
tests/EntKube.Clusters.Tests/Features/IngressCheckTests.cs
Normal file
194
tests/EntKube.Clusters.Tests/Features/IngressCheckTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
127
tests/EntKube.Clusters.Tests/Features/IngressInstallerTests.cs
Normal file
127
tests/EntKube.Clusters.Tests/Features/IngressInstallerTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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" }));
|
||||
}
|
||||
}
|
||||
546
tests/EntKube.Clusters.Tests/Features/KeycloakTests.cs
Normal file
546
tests/EntKube.Clusters.Tests/Features/KeycloakTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
748
tests/EntKube.Clusters.Tests/Features/LetsEncryptCheckTests.cs
Normal file
748
tests/EntKube.Clusters.Tests/Features/LetsEncryptCheckTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
111
tests/EntKube.Clusters.Tests/Features/SetProviderHandlerTests.cs
Normal file
111
tests/EntKube.Clusters.Tests/Features/SetProviderHandlerTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
739
tests/EntKube.Clusters.Tests/Features/WorkloadRemediatorTests.cs
Normal file
739
tests/EntKube.Clusters.Tests/Features/WorkloadRemediatorTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user