452 lines
16 KiB
C#
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>();
|
|
}
|
|
}
|