Files
Entkube/tests/EntKube.Clusters.Tests/Domain/KubernetesClusterTests.cs
2026-05-13 14:01:32 +02:00

452 lines
16 KiB
C#

using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Domain;
public class KubernetesClusterTests
{
[Fact]
public void Register_WithValidInputs_CreatesClusterInPendingState()
{
// Arrange & Act — A tenant admin registers a new cluster with its name, API URL,
// kubeconfig, tenant, and environment it belongs to.
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
KubernetesCluster cluster = KubernetesCluster.Register(
"production-eu",
"https://k8s.example.com:6443",
"apiVersion: v1\nkind: Config",
tenantId,
"prod-context",
environmentId);
// Assert — The cluster should be created with a unique ID, linked to the tenant
// and environment, and start in Pending state because we haven't verified connectivity yet.
cluster.Id.Should().NotBe(Guid.Empty);
cluster.Name.Should().Be("production-eu");
cluster.ApiServerUrl.Should().Be("https://k8s.example.com:6443");
cluster.KubeConfig.Should().Contain("apiVersion");
cluster.TenantId.Should().Be(tenantId);
cluster.EnvironmentId.Should().Be(environmentId);
cluster.ContextName.Should().Be("prod-context");
cluster.Status.Should().Be(ClusterStatus.Pending);
cluster.RegisteredAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void Register_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act — Attempting to register a cluster without a name should fail
// because every cluster needs a human-readable identifier.
Action act = () => KubernetesCluster.Register("", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("name");
}
[Fact]
public void Register_WithEmptyApiServerUrl_ThrowsArgumentException()
{
// Arrange & Act — Without an API URL, we can't communicate with the cluster.
Action act = () => KubernetesCluster.Register("my-cluster", "", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("apiServerUrl");
}
[Fact]
public void MarkConnected_SetsStatusToConnected()
{
// Arrange — A freshly registered cluster in Pending state.
KubernetesCluster cluster = KubernetesCluster.Register("test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Act — The health check confirms the cluster is reachable.
cluster.MarkConnected();
// Assert
cluster.Status.Should().Be(ClusterStatus.Connected);
cluster.LastHealthCheckAt.Should().NotBeNull();
}
[Fact]
public void MarkUnreachable_SetsStatusToUnreachable()
{
// Arrange
KubernetesCluster cluster = KubernetesCluster.Register("test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
// Act — The health check fails.
cluster.MarkUnreachable();
// Assert
cluster.Status.Should().Be(ClusterStatus.Unreachable);
cluster.LastHealthCheckAt.Should().NotBeNull();
}
[Fact]
public void Register_WithEnvironmentId_StoresEnvironmentId()
{
// Arrange & Act — A cluster is registered and immediately assigned
// to an environment (e.g. dev01 → "dev" environment).
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
KubernetesCluster cluster = KubernetesCluster.Register(
"dev01", "https://api.dev", "kubeconfig", tenantId, "dev-ctx", environmentId);
// Assert — The environment should be stored on the cluster.
cluster.EnvironmentId.Should().Be(environmentId);
}
[Fact]
public void Register_WithoutEnvironmentId_ThrowsArgumentException()
{
// Arrange & Act — Attempting to register a cluster without an environment
// should fail because every cluster must belong to an environment.
Action act = () => KubernetesCluster.Register(
"orphan", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.Empty);
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("environmentId");
}
[Fact]
public void AssignToEnvironment_ReassignsToNewEnvironment()
{
// Arrange — A cluster registered in one environment can be reassigned.
Guid originalEnvId = Guid.NewGuid();
Guid newEnvId = Guid.NewGuid();
KubernetesCluster cluster = KubernetesCluster.Register(
"test01", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", originalEnvId);
// Act — Admin reassigns it to a different environment.
cluster.AssignToEnvironment(newEnvId);
// Assert
cluster.EnvironmentId.Should().Be(newEnvId);
}
[Fact]
public void AssignToEnvironment_WithEmptyGuid_ThrowsArgumentException()
{
// Arrange
KubernetesCluster cluster = KubernetesCluster.Register(
"test01", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Act
Action act = () => cluster.AssignToEnvironment(Guid.Empty);
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("environmentId");
}
[Fact]
public void Register_CreatesClusterWithEmptyComponents()
{
// Arrange & Act — A freshly registered cluster has no known components yet.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Assert
cluster.Components.Should().BeEmpty();
}
[Fact]
public void UpdateComponents_WithCheckResults_PersistsComponents()
{
// Arrange — After an adoption scan, we record the discovered components
// on the cluster so they survive without needing to re-scan.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> results = 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> { ["replicas"] = "3" })),
new ComponentCheckResult("cert-manager", ComponentStatus.NotInstalled,
new List<string>(), new List<string> { "No pods found" })
};
// Act
cluster.UpdateComponents(results);
// Assert — Both components stored with their status and configuration.
cluster.Components.Should().HaveCount(2);
ClusterComponent kyverno = cluster.Components.First(c => c.ComponentName == "kyverno");
kyverno.Status.Should().Be(ComponentStatus.Installed);
kyverno.Version.Should().Be("3.3.4");
kyverno.Namespace.Should().Be("kyverno");
kyverno.Configuration["replicas"].Should().Be("3");
kyverno.LastCheckedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
ClusterComponent certManager = cluster.Components.First(c => c.ComponentName == "cert-manager");
certManager.Status.Should().Be(ComponentStatus.NotInstalled);
certManager.Configuration.Should().BeEmpty();
}
[Fact]
public void UpdateComponents_ReplacesExistingComponents()
{
// Arrange — A second scan should replace the first scan's results entirely.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> firstScan = new()
{
new ComponentCheckResult("kyverno", ComponentStatus.NotInstalled, new List<string>(), new List<string> { "Missing" })
};
List<ComponentCheckResult> secondScan = new()
{
new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Running" }, new List<string>(),
new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno", new Dictionary<string, string>()))
};
// Act
cluster.UpdateComponents(firstScan);
cluster.UpdateComponents(secondScan);
// Assert — Only the latest scan results should remain.
cluster.Components.Should().HaveCount(1);
cluster.Components[0].Status.Should().Be(ComponentStatus.Installed);
}
[Fact]
public void UpdateComponentConfiguration_UpdatesExistingComponentValues()
{
// Arrange — After a configuration change is deployed, we update the
// stored configuration so it reflects the current state.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> results = new()
{
new ComponentCheckResult("monitoring", ComponentStatus.Installed,
new List<string>(), new List<string>(),
new DiscoveredConfiguration("45.0.0", "monitoring", "kube-prometheus-stack",
new Dictionary<string, string> { ["retention"] = "30d", ["replicas"] = "2" }))
};
cluster.UpdateComponents(results);
// Act — Change retention to 90d.
Dictionary<string, string> newValues = new() { ["retention"] = "90d" };
cluster.UpdateComponentConfiguration("monitoring", newValues);
// Assert — Retention updated, replicas unchanged.
ClusterComponent monitoring = cluster.Components.First(c => c.ComponentName == "monitoring");
monitoring.Configuration["retention"].Should().Be("90d");
monitoring.Configuration["replicas"].Should().Be("2");
}
[Fact]
public void UpdateComponentConfiguration_ForUnknownComponent_DoesNotThrow()
{
// Arrange — If a component hasn't been scanned yet, updating its config
// is a no-op. This shouldn't break anything.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
// Act — No components recorded yet.
Action act = () => cluster.UpdateComponentConfiguration("unknown", new Dictionary<string, string> { ["k"] = "v" });
// Assert
act.Should().NotThrow();
cluster.Components.Should().BeEmpty();
}
[Fact]
public void UpdateComponentVersion_ForInstalledComponent_UpdatesVersion()
{
// Arrange — A cluster has Harbor installed at version 1.16.0. After a
// successful upgrade, we need to track the new version.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> results = 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(results);
// Act — Upgrade to 1.17.0.
cluster.UpdateComponentVersion("harbor", "1.17.0");
// Assert — The tracked version now reflects the upgrade.
ClusterComponent harbor = cluster.Components.First(c => c.ComponentName == "harbor");
harbor.Version.Should().Be("1.17.0");
harbor.LastCheckedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void UpdateComponentVersion_ForUnknownComponent_DoesNotThrow()
{
// Arrange — If the component hasn't been scanned, updating its version
// is a safe no-op — no exception, no side effects.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
// Act
Action act = () => cluster.UpdateComponentVersion("nonexistent", "1.0.0");
// Assert
act.Should().NotThrow();
cluster.Components.Should().BeEmpty();
}
[Fact]
public void UpdateComponentVersion_IsCaseInsensitive()
{
// Arrange — Component names should match case-insensitively, just like
// MarkComponentInstalled and UpdateComponentConfiguration.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> results = new()
{
new ComponentCheckResult("Harbor", ComponentStatus.Installed,
new List<string>(), new List<string>(),
new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary<string, string>()))
};
cluster.UpdateComponents(results);
// Act — Use different casing.
cluster.UpdateComponentVersion("harbor", "1.17.0");
// Assert
cluster.Components.First().Version.Should().Be("1.17.0");
}
[Fact]
public void Register_DefaultsToNoneProvider()
{
// When a cluster is first registered, no cloud provider is set.
// The operator can assign one later when they know where it's hosted.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.Provider.Should().Be(CloudProvider.None);
cluster.ProviderCredentials.Should().BeNull();
}
[Fact]
public void SetProvider_WithCleura_StoresProviderAndCredentials()
{
// A cluster admin links their cluster to Cleura, providing their
// Cleura Cloud username and password so EntKube can call the REST API.
KubernetesCluster cluster = KubernetesCluster.Register(
"cleura-cluster", "https://k8s.cleura.cloud", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
ProviderCredentials creds = new("my-user", "my-password", "Sto2");
// Act
cluster.SetProvider(CloudProvider.Cleura, creds);
// Assert
cluster.Provider.Should().Be(CloudProvider.Cleura);
cluster.ProviderCredentials.Should().NotBeNull();
cluster.ProviderCredentials!.Username.Should().Be("my-user");
cluster.ProviderCredentials!.Region.Should().Be("Sto2");
}
[Fact]
public void SetProvider_ToNone_ClearsCredentials()
{
// Unlinking a provider removes the stored credentials.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.SetProvider(CloudProvider.Cleura, new ProviderCredentials("u", "p", "Sto2"));
// Act
cluster.SetProvider(CloudProvider.None, null);
// Assert
cluster.Provider.Should().Be(CloudProvider.None);
cluster.ProviderCredentials.Should().BeNull();
}
[Fact]
public void SetProvider_CleuraWithoutCredentials_Throws()
{
// Setting Cleura as the provider requires credentials — you can't
// call the Cleura API without a username and password.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
Action act = () => cluster.SetProvider(CloudProvider.Cleura, null);
act.Should().Throw<ArgumentException>();
}
}