too much for one commit
This commit is contained in:
180
tests/EntKube.Provisioning.Tests/Domain/AppEnvironmentTests.cs
Normal file
180
tests/EntKube.Provisioning.Tests/Domain/AppEnvironmentTests.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Domain;
|
||||
|
||||
public class AppEnvironmentTests
|
||||
{
|
||||
// ─── ConfigureDeployment ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ConfigureDeployment_WithValidSpec_SetsDeploymentSpec()
|
||||
{
|
||||
// Arrange — After adding an environment, the admin configures
|
||||
// the deployment details: which container image, how many replicas,
|
||||
// port mappings, and routing. This is the equivalent of writing
|
||||
// deployment.yaml + service.yaml + httproute.yaml by hand.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
|
||||
|
||||
DeploymentSpec spec = new(
|
||||
Image: "registry.example.com/api",
|
||||
Tag: "v1.2.3",
|
||||
Replicas: 2,
|
||||
ContainerPort: 8080,
|
||||
ServicePort: 80,
|
||||
HostName: "api.dev.example.com",
|
||||
PathPrefix: "/",
|
||||
EnvironmentVariables: new Dictionary<string, string> { ["ASPNETCORE_ENVIRONMENT"] = "Development" },
|
||||
Resources: new ResourceSpec("100m", "500m", "128Mi", "512Mi"));
|
||||
|
||||
// Act
|
||||
|
||||
env.ConfigureDeployment(spec);
|
||||
|
||||
// Assert
|
||||
|
||||
env.DeploymentSpec.Should().NotBeNull();
|
||||
env.DeploymentSpec!.Image.Should().Be("registry.example.com/api");
|
||||
env.DeploymentSpec.Tag.Should().Be("v1.2.3");
|
||||
env.DeploymentSpec.Replicas.Should().Be(2);
|
||||
env.DeploymentSpec.ContainerPort.Should().Be(8080);
|
||||
env.DeploymentSpec.ServicePort.Should().Be(80);
|
||||
env.DeploymentSpec.HostName.Should().Be("api.dev.example.com");
|
||||
env.DeploymentSpec.Resources.Should().NotBeNull();
|
||||
env.SyncStatus.Should().Be(AppSyncStatus.Pending, "changing config resets sync to pending");
|
||||
}
|
||||
|
||||
// ─── ConfigureHelmRelease ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ConfigureHelmRelease_WithValidSpec_SetsHelmSpec()
|
||||
{
|
||||
// Arrange — For a Helm-type app, the admin points to a Helm chart
|
||||
// repository, selects the chart and version, and provides a values.yaml.
|
||||
// This is the equivalent of `helm install` with custom values.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "Redis", "redis", AppType.HelmChart);
|
||||
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "redis-dev");
|
||||
|
||||
HelmReleaseSpec spec = new(
|
||||
RepoUrl: "https://charts.bitnami.com/bitnami",
|
||||
ChartName: "redis",
|
||||
ChartVersion: "18.6.1",
|
||||
ValuesYaml: "replica:\n replicaCount: 3");
|
||||
|
||||
// Act
|
||||
|
||||
env.ConfigureHelmRelease(spec);
|
||||
|
||||
// Assert
|
||||
|
||||
env.HelmReleaseSpec.Should().NotBeNull();
|
||||
env.HelmReleaseSpec!.RepoUrl.Should().Be("https://charts.bitnami.com/bitnami");
|
||||
env.HelmReleaseSpec.ChartName.Should().Be("redis");
|
||||
env.HelmReleaseSpec.ChartVersion.Should().Be("18.6.1");
|
||||
env.HelmReleaseSpec.ValuesYaml.Should().Contain("replicaCount: 3");
|
||||
env.SyncStatus.Should().Be(AppSyncStatus.Pending);
|
||||
}
|
||||
|
||||
// ─── Secrets ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AddSecret_WithValidInputs_AddsSecretReference()
|
||||
{
|
||||
// Arrange — Secrets for an app environment are stored in the vault.
|
||||
// Here we add a reference that maps a Kubernetes secret key name
|
||||
// to the vault path where the actual secret value lives.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
|
||||
|
||||
// Act
|
||||
|
||||
env.AddSecret("DATABASE_URL", "database-connection-string");
|
||||
|
||||
// Assert
|
||||
|
||||
env.Secrets.Should().HaveCount(1);
|
||||
env.Secrets[0].Name.Should().Be("DATABASE_URL");
|
||||
env.Secrets[0].VaultKey.Should().Be("database-connection-string");
|
||||
env.SyncStatus.Should().Be(AppSyncStatus.Pending, "adding a secret resets sync");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecret_DuplicateName_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange — Each secret name must be unique within an environment.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
|
||||
env.AddSecret("DATABASE_URL", "db-conn-string");
|
||||
|
||||
// Act
|
||||
|
||||
Action act = () => env.AddSecret("DATABASE_URL", "another-path");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*already exists*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSecret_ExistingSecret_RemovesIt()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
|
||||
env.AddSecret("DATABASE_URL", "db-conn-string");
|
||||
|
||||
// Act
|
||||
|
||||
env.RemoveSecret("DATABASE_URL");
|
||||
|
||||
// Assert
|
||||
|
||||
env.Secrets.Should().BeEmpty();
|
||||
env.SyncStatus.Should().Be(AppSyncStatus.Pending);
|
||||
}
|
||||
|
||||
// ─── Sync Status ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MarkSynced_UpdatesSyncStatus()
|
||||
{
|
||||
// Arrange — The reconciler successfully deployed the app.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
|
||||
|
||||
// Act
|
||||
|
||||
env.MarkSynced();
|
||||
|
||||
// Assert
|
||||
|
||||
env.SyncStatus.Should().Be(AppSyncStatus.Synced);
|
||||
env.LastSyncAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkError_SetsSyncStatusAndMessage()
|
||||
{
|
||||
// Arrange — Something went wrong during deployment.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
|
||||
|
||||
// Act
|
||||
|
||||
env.MarkError("ImagePullBackOff: registry.example.com/api:v1.2.3");
|
||||
|
||||
// Assert
|
||||
|
||||
env.SyncStatus.Should().Be(AppSyncStatus.Error);
|
||||
env.SyncStatusMessage.Should().Contain("ImagePullBackOff");
|
||||
}
|
||||
}
|
||||
229
tests/EntKube.Provisioning.Tests/Domain/AppTests.cs
Normal file
229
tests/EntKube.Provisioning.Tests/Domain/AppTests.cs
Normal file
@@ -0,0 +1,229 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Domain;
|
||||
|
||||
public class AppTests
|
||||
{
|
||||
// ─── Create ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_WithValidInputs_CreatesActiveApp()
|
||||
{
|
||||
// Arrange — A customer admin creates a new app (e.g. "frontend-portal")
|
||||
// that will eventually be deployed across environments. The app starts
|
||||
// in an Active state and is ready to have environments added.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid customerId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
|
||||
App app = App.Create(tenantId, customerId, "Frontend Portal", "frontend-portal", AppType.Deployment);
|
||||
|
||||
// Assert — The app should be active and linked to its customer and tenant.
|
||||
|
||||
app.Id.Should().NotBe(Guid.Empty);
|
||||
app.TenantId.Should().Be(tenantId);
|
||||
app.CustomerId.Should().Be(customerId);
|
||||
app.Name.Should().Be("Frontend Portal");
|
||||
app.Slug.Should().Be("frontend-portal");
|
||||
app.Type.Should().Be(AppType.Deployment);
|
||||
app.Status.Should().Be(AppStatus.Active);
|
||||
app.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
app.Environments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_NormalizesSlugToLowerCase()
|
||||
{
|
||||
// Arrange & Act — Slugs should always be lowercase for consistency.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "My App", "MY-APP", AppType.HelmChart);
|
||||
|
||||
// Assert
|
||||
|
||||
app.Slug.Should().Be("my-app");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyTenantId_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => App.Create(Guid.Empty, Guid.NewGuid(), "App", "app", AppType.Deployment);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("tenantId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyCustomerId_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => App.Create(Guid.NewGuid(), Guid.Empty, "App", "app", AppType.Deployment);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("customerId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => App.Create(Guid.NewGuid(), Guid.NewGuid(), "", "app", AppType.Deployment);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptySlug_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "", AppType.Deployment);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("slug");
|
||||
}
|
||||
|
||||
// ─── Suspend / Activate ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Suspend_SetsStatusToSuspended()
|
||||
{
|
||||
// Arrange — An active app that the admin wants to pause deployments for.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
|
||||
|
||||
// Act
|
||||
|
||||
app.Suspend();
|
||||
|
||||
// Assert
|
||||
|
||||
app.Status.Should().Be(AppStatus.Suspended);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_SetsStatusToActive()
|
||||
{
|
||||
// Arrange — A previously suspended app being re-enabled.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
|
||||
app.Suspend();
|
||||
|
||||
// Act
|
||||
|
||||
app.Activate();
|
||||
|
||||
// Assert
|
||||
|
||||
app.Status.Should().Be(AppStatus.Active);
|
||||
}
|
||||
|
||||
// ─── AddEnvironment ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AddEnvironment_WithValidInputs_CreatesAppEnvironment()
|
||||
{
|
||||
// Arrange — After creating an app, the admin adds it to a specific
|
||||
// environment (e.g. "dev"). This links the app to a cluster and
|
||||
// namespace where it will be deployed.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API Service", "api-service", AppType.Deployment);
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
|
||||
AppEnvironment env = app.AddEnvironment(environmentId, clusterId, "api-dev");
|
||||
|
||||
// Assert — The environment should be created in Pending state.
|
||||
|
||||
env.Id.Should().NotBe(Guid.Empty);
|
||||
env.EnvironmentId.Should().Be(environmentId);
|
||||
env.ClusterId.Should().Be(clusterId);
|
||||
env.Namespace.Should().Be("api-dev");
|
||||
env.SyncStatus.Should().Be(AppSyncStatus.Pending);
|
||||
app.Environments.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEnvironment_DuplicateEnvironmentId_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange — An app can only be deployed to each environment once.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
app.AddEnvironment(environmentId, Guid.NewGuid(), "app-dev");
|
||||
|
||||
// Act
|
||||
|
||||
Action act = () => app.AddEnvironment(environmentId, Guid.NewGuid(), "app-dev-2");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*already*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEnvironment_WithEmptyNamespace_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
|
||||
Action act = () => app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("ns");
|
||||
}
|
||||
|
||||
// ─── RemoveEnvironment ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RemoveEnvironment_ExistingEnvironment_RemovesIt()
|
||||
{
|
||||
// Arrange — An app deployed to dev that the admin wants to remove.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
app.AddEnvironment(environmentId, Guid.NewGuid(), "app-dev");
|
||||
|
||||
// Act
|
||||
|
||||
app.RemoveEnvironment(environmentId);
|
||||
|
||||
// Assert
|
||||
|
||||
app.Environments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveEnvironment_NonExistent_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
|
||||
Action act = () => app.RemoveEnvironment(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*not found*");
|
||||
}
|
||||
}
|
||||
360
tests/EntKube.Provisioning.Tests/Domain/GrafanaInstanceTests.cs
Normal file
360
tests/EntKube.Provisioning.Tests/Domain/GrafanaInstanceTests.cs
Normal file
@@ -0,0 +1,360 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Domain;
|
||||
|
||||
public class GrafanaInstanceTests
|
||||
{
|
||||
private readonly Guid environmentId = Guid.NewGuid();
|
||||
private readonly Guid clusterId = Guid.NewGuid();
|
||||
|
||||
// ─── Creation ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_WithValidInputs_CreatesInstanceInPendingState()
|
||||
{
|
||||
// Arrange & Act — An operator provisions a Grafana instance for an environment.
|
||||
// Grafana is per-environment: one centralized visualization for all clusters
|
||||
// in that environment, deployed to a specific cluster.
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(
|
||||
environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
// Assert — The instance should be in Pending state with sensible defaults.
|
||||
|
||||
instance.Id.Should().NotBe(Guid.Empty);
|
||||
instance.EnvironmentId.Should().Be(environmentId);
|
||||
instance.ClusterId.Should().Be(clusterId);
|
||||
instance.Name.Should().Be("prod-grafana");
|
||||
instance.Namespace.Should().Be("monitoring");
|
||||
instance.Status.Should().Be(GrafanaInstanceStatus.Pending);
|
||||
instance.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Grafana defaults
|
||||
|
||||
instance.Persistence.Should().BeFalse();
|
||||
instance.PersistenceSize.Should().Be("10Gi");
|
||||
instance.Oidc.Should().BeNull();
|
||||
instance.Ingress.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyEnvironmentId_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — Grafana must belong to an environment.
|
||||
|
||||
Action act = () => GrafanaInstance.Create(Guid.Empty, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("environmentId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyClusterId_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — Grafana must be deployed to a specific cluster.
|
||||
|
||||
Action act = () => GrafanaInstance.Create(environmentId, Guid.Empty, "prod-grafana", "monitoring");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("clusterId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => GrafanaInstance.Create(environmentId, clusterId, "", "monitoring");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyNamespace_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("ns");
|
||||
}
|
||||
|
||||
// ─── Persistence Configuration ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ConfigurePersistence_UpdatesSettings()
|
||||
{
|
||||
// Arrange — An operator wants Grafana to keep dashboards across restarts.
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
// Act
|
||||
|
||||
instance.ConfigurePersistence(enabled: true, size: "20Gi");
|
||||
|
||||
// Assert
|
||||
|
||||
instance.Persistence.Should().BeTrue();
|
||||
instance.PersistenceSize.Should().Be("20Gi");
|
||||
}
|
||||
|
||||
// ─── OIDC Configuration ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ConfigureOidc_SetsOidcProvider()
|
||||
{
|
||||
// Arrange — Integrate Grafana with an OIDC provider (e.g., Entra ID)
|
||||
// so users authenticate with corporate credentials.
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
// Act
|
||||
|
||||
OidcConfiguration oidc = new(
|
||||
Provider: "generic_oauth",
|
||||
ClientId: "grafana-client-id",
|
||||
ClientSecretRef: "vault://monitoring/grafana-oidc-client-secret",
|
||||
AuthUrl: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize",
|
||||
TokenUrl: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token",
|
||||
ApiUrl: "https://graph.microsoft.com/oidc/userinfo",
|
||||
Scopes: "openid profile email",
|
||||
RoleAttributePath: "contains(groups[*], 'admin-group-id') && 'Admin' || 'Viewer'",
|
||||
AutoLogin: true);
|
||||
|
||||
instance.ConfigureOidc(oidc);
|
||||
|
||||
// Assert
|
||||
|
||||
instance.Oidc.Should().NotBeNull();
|
||||
instance.Oidc!.Provider.Should().Be("generic_oauth");
|
||||
instance.Oidc.ClientId.Should().Be("grafana-client-id");
|
||||
instance.Oidc.ClientSecretRef.Should().Be("vault://monitoring/grafana-oidc-client-secret");
|
||||
instance.Oidc.AutoLogin.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigureOidc_WithEmptyClientId_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
// Act & Assert
|
||||
|
||||
OidcConfiguration oidc = new(
|
||||
Provider: "generic_oauth",
|
||||
ClientId: "",
|
||||
ClientSecretRef: "vault://secret",
|
||||
AuthUrl: "https://example.com/auth",
|
||||
TokenUrl: "https://example.com/token",
|
||||
ApiUrl: "https://example.com/userinfo",
|
||||
Scopes: "openid",
|
||||
RoleAttributePath: null,
|
||||
AutoLogin: false);
|
||||
|
||||
Action act = () => instance.ConfigureOidc(oidc);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveOidc_ClearsOidcConfiguration()
|
||||
{
|
||||
// Arrange — A Grafana instance with OIDC configured.
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
OidcConfiguration oidc = new(
|
||||
Provider: "generic_oauth",
|
||||
ClientId: "client-id",
|
||||
ClientSecretRef: "vault://secret",
|
||||
AuthUrl: "https://example.com/auth",
|
||||
TokenUrl: "https://example.com/token",
|
||||
ApiUrl: "https://example.com/userinfo",
|
||||
Scopes: "openid",
|
||||
RoleAttributePath: null,
|
||||
AutoLogin: false);
|
||||
|
||||
instance.ConfigureOidc(oidc);
|
||||
|
||||
// Act — Remove OIDC, fall back to local auth.
|
||||
|
||||
instance.RemoveOidc();
|
||||
|
||||
// Assert
|
||||
|
||||
instance.Oidc.Should().BeNull();
|
||||
}
|
||||
|
||||
// ─── Ingress ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ConfigureIngress_SetsGrafanaRouting()
|
||||
{
|
||||
// Arrange — Publish Grafana externally with its own hostname.
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
// Act
|
||||
|
||||
GrafanaIngressConfiguration ingress = new(
|
||||
Enabled: true,
|
||||
Provider: IngressProvider.GatewayApi,
|
||||
Hostname: "grafana.prod01.example.com",
|
||||
TlsEnabled: true,
|
||||
TlsCertificateMode: TlsCertificateMode.CertManager,
|
||||
TlsSecretName: null,
|
||||
ClusterIssuer: "letsencrypt-prod");
|
||||
|
||||
instance.ConfigureIngress(ingress);
|
||||
|
||||
// Assert
|
||||
|
||||
instance.Ingress.Should().NotBeNull();
|
||||
instance.Ingress!.Enabled.Should().BeTrue();
|
||||
instance.Ingress.Provider.Should().Be(IngressProvider.GatewayApi);
|
||||
instance.Ingress.Hostname.Should().Be("grafana.prod01.example.com");
|
||||
instance.Ingress.TlsCertificateMode.Should().Be(TlsCertificateMode.CertManager);
|
||||
instance.Ingress.ClusterIssuer.Should().Be("letsencrypt-prod");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisableIngress_ClearsIngressConfig()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
GrafanaIngressConfiguration ingress = new(
|
||||
Enabled: true,
|
||||
Provider: IngressProvider.GatewayApi,
|
||||
Hostname: "grafana.example.com",
|
||||
TlsEnabled: true,
|
||||
TlsCertificateMode: TlsCertificateMode.Manual,
|
||||
TlsSecretName: "grafana-tls");
|
||||
|
||||
instance.ConfigureIngress(ingress);
|
||||
|
||||
// Act
|
||||
|
||||
instance.DisableIngress();
|
||||
|
||||
// Assert
|
||||
|
||||
instance.Ingress.Should().BeNull();
|
||||
}
|
||||
|
||||
// ─── Dashboards ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AddDashboard_StoresDashboardDefinition()
|
||||
{
|
||||
// Arrange — Dashboards are JSON definitions deployed as ConfigMaps
|
||||
// with the grafana_dashboard=1 label so the sidecar picks them up.
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
// Act — Add a Kubernetes cluster dashboard.
|
||||
|
||||
GrafanaDashboard dashboard = new(
|
||||
Name: "k8s-cluster",
|
||||
DisplayName: "Kubernetes Cluster",
|
||||
Category: DashboardCategory.Kubernetes,
|
||||
JsonContent: """{"dashboard": {"title": "K8s Cluster"}}""");
|
||||
|
||||
instance.AddDashboard(dashboard);
|
||||
|
||||
// Assert
|
||||
|
||||
instance.Dashboards.Should().HaveCount(1);
|
||||
instance.Dashboards[0].Name.Should().Be("k8s-cluster");
|
||||
instance.Dashboards[0].Category.Should().Be(DashboardCategory.Kubernetes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDashboard_WithDuplicateName_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
GrafanaDashboard dashboard = new(
|
||||
Name: "k8s-cluster",
|
||||
DisplayName: "Kubernetes Cluster",
|
||||
Category: DashboardCategory.Kubernetes,
|
||||
JsonContent: """{"dashboard": {}}""");
|
||||
|
||||
instance.AddDashboard(dashboard);
|
||||
|
||||
// Act & Assert — Can't add two dashboards with the same name.
|
||||
|
||||
Action act = () => instance.AddDashboard(dashboard);
|
||||
|
||||
act.Should().Throw<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveDashboard_DeletesByName()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
instance.AddDashboard(new GrafanaDashboard("k8s-cluster", "Kubernetes Cluster", DashboardCategory.Kubernetes, "{}"));
|
||||
instance.AddDashboard(new GrafanaDashboard("istio-mesh", "Istio Mesh", DashboardCategory.Istio, "{}"));
|
||||
|
||||
// Act
|
||||
|
||||
instance.RemoveDashboard("k8s-cluster");
|
||||
|
||||
// Assert
|
||||
|
||||
instance.Dashboards.Should().HaveCount(1);
|
||||
instance.Dashboards[0].Name.Should().Be("istio-mesh");
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MarkRunning_SetsStatusToRunning()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
// Act
|
||||
|
||||
instance.MarkRunning();
|
||||
|
||||
// Assert
|
||||
|
||||
instance.Status.Should().Be(GrafanaInstanceStatus.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDegraded_SetsStatusToDegraded()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
|
||||
|
||||
// Act
|
||||
|
||||
instance.MarkDegraded();
|
||||
|
||||
// Assert
|
||||
|
||||
instance.Status.Should().Be(GrafanaInstanceStatus.Degraded);
|
||||
}
|
||||
}
|
||||
192
tests/EntKube.Provisioning.Tests/Domain/KeycloakRealmTests.cs
Normal file
192
tests/EntKube.Provisioning.Tests/Domain/KeycloakRealmTests.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Domain;
|
||||
|
||||
public class IdentityRealmTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_ValidName_ReturnsRealmInProvisioningState()
|
||||
{
|
||||
// When creating a new realm, it starts in Provisioning status
|
||||
// with the given name and cluster association.
|
||||
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
IdentityRealm realm = IdentityRealm.Create(environmentId, "my-app", clusterId);
|
||||
|
||||
realm.Id.Should().NotBeEmpty();
|
||||
realm.EnvironmentId.Should().Be(environmentId);
|
||||
realm.ClusterId.Should().Be(clusterId);
|
||||
realm.Name.Should().Be("my-app");
|
||||
realm.Status.Should().Be(IdentityRealmStatus.Provisioning);
|
||||
realm.Branding.Should().Be(RealmBranding.Default);
|
||||
realm.Pages.LoginEnabled.Should().BeTrue();
|
||||
realm.Pages.ProfileEnabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_EmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// A realm must have a name — empty is not valid.
|
||||
|
||||
Action act = () => IdentityRealm.Create(Guid.NewGuid(), "", Guid.NewGuid());
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateBranding_ChangesBrandingAndTimestamp()
|
||||
{
|
||||
// After updating branding, the new values stick and
|
||||
// the last modified timestamp is updated.
|
||||
|
||||
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "branded", Guid.NewGuid());
|
||||
|
||||
RealmBranding newBranding = new(
|
||||
LogoUrl: "https://example.com/logo.png",
|
||||
BackgroundImageUrl: null,
|
||||
BackgroundColor: "#1a1a2e",
|
||||
PrimaryColor: "#16213e",
|
||||
SecondaryColor: "#0f3460",
|
||||
TextColor: "#ffffff");
|
||||
|
||||
realm.UpdateBranding(newBranding);
|
||||
|
||||
realm.Branding.LogoUrl.Should().Be("https://example.com/logo.png");
|
||||
realm.Branding.BackgroundColor.Should().Be("#1a1a2e");
|
||||
realm.LastModifiedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePages_EnablesProfile()
|
||||
{
|
||||
// A realm starts with only login enabled. We can enable profile pages too.
|
||||
|
||||
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "with-profile", Guid.NewGuid());
|
||||
|
||||
RealmPages pages = new(
|
||||
LoginEnabled: true,
|
||||
ProfileEnabled: true,
|
||||
RegistrationEnabled: false,
|
||||
ForgotPasswordEnabled: true);
|
||||
|
||||
realm.UpdatePages(pages);
|
||||
|
||||
realm.Pages.ProfileEnabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddIdentityProvider_NewProvider_AddsToList()
|
||||
{
|
||||
// Adding an OIDC identity provider to the realm.
|
||||
|
||||
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "idp-realm", Guid.NewGuid());
|
||||
|
||||
RealmIdentityProvider provider = RealmIdentityProvider.Create(
|
||||
alias: "azure-ad",
|
||||
displayName: "Azure AD",
|
||||
type: IdentityProviderType.Oidc,
|
||||
authorizationUrl: "https://login.microsoftonline.com/authorize",
|
||||
tokenUrl: "https://login.microsoftonline.com/token",
|
||||
clientId: "my-client-id");
|
||||
|
||||
realm.AddIdentityProvider(provider);
|
||||
|
||||
realm.IdentityProviders.Should().HaveCount(1);
|
||||
realm.IdentityProviders[0].Alias.Should().Be("azure-ad");
|
||||
realm.IdentityProviders[0].Type.Should().Be(IdentityProviderType.Oidc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddIdentityProvider_DuplicateAlias_Throws()
|
||||
{
|
||||
// Each provider must have a unique alias within the realm.
|
||||
|
||||
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "dup-realm", Guid.NewGuid());
|
||||
|
||||
RealmIdentityProvider first = RealmIdentityProvider.Create("github", "GitHub", IdentityProviderType.GitHub);
|
||||
RealmIdentityProvider duplicate = RealmIdentityProvider.Create("github", "GitHub Copy", IdentityProviderType.GitHub);
|
||||
|
||||
realm.AddIdentityProvider(first);
|
||||
|
||||
Action act = () => realm.AddIdentityProvider(duplicate);
|
||||
|
||||
act.Should().Throw<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveIdentityProvider_ExistingAlias_RemovesFromList()
|
||||
{
|
||||
// Removing an identity provider by alias.
|
||||
|
||||
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "remove-realm", Guid.NewGuid());
|
||||
|
||||
RealmIdentityProvider provider = RealmIdentityProvider.Create("google", "Google", IdentityProviderType.Google);
|
||||
realm.AddIdentityProvider(provider);
|
||||
|
||||
realm.RemoveIdentityProvider("google");
|
||||
|
||||
realm.IdentityProviders.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOrganization_NewOrg_AddsToList()
|
||||
{
|
||||
// Organizations let you group users within a realm.
|
||||
|
||||
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "org-realm", Guid.NewGuid());
|
||||
|
||||
RealmOrganization org = RealmOrganization.Create("Acme Corp", "Top-level org");
|
||||
|
||||
realm.AddOrganization(org);
|
||||
|
||||
realm.Organizations.Should().HaveCount(1);
|
||||
realm.Organizations[0].Name.Should().Be("Acme Corp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Organization_AddChild_CreatesHierarchy()
|
||||
{
|
||||
// Organizations support nested children for hierarchical structure.
|
||||
|
||||
RealmOrganization parent = RealmOrganization.Create("Acme Corp");
|
||||
RealmOrganization child = RealmOrganization.Create("Engineering");
|
||||
RealmOrganization grandchild = RealmOrganization.Create("Backend Team");
|
||||
|
||||
parent.AddChild(child);
|
||||
child.AddChild(grandchild);
|
||||
|
||||
parent.Children.Should().HaveCount(1);
|
||||
parent.Children[0].Name.Should().Be("Engineering");
|
||||
parent.Children[0].Children.Should().HaveCount(1);
|
||||
parent.Children[0].Children[0].Name.Should().Be("Backend Team");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Organization_DuplicateChildName_Throws()
|
||||
{
|
||||
// Child names must be unique within a parent.
|
||||
|
||||
RealmOrganization parent = RealmOrganization.Create("Acme Corp");
|
||||
RealmOrganization child1 = RealmOrganization.Create("Engineering");
|
||||
RealmOrganization child2 = RealmOrganization.Create("Engineering");
|
||||
|
||||
parent.AddChild(child1);
|
||||
|
||||
Action act = () => parent.AddChild(child2);
|
||||
|
||||
act.Should().Throw<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkActive_ChangesStatusToActive()
|
||||
{
|
||||
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "active-realm", Guid.NewGuid());
|
||||
|
||||
realm.MarkActive();
|
||||
|
||||
realm.Status.Should().Be(IdentityRealmStatus.Active);
|
||||
}
|
||||
}
|
||||
204
tests/EntKube.Provisioning.Tests/Domain/MinioInstanceTests.cs
Normal file
204
tests/EntKube.Provisioning.Tests/Domain/MinioInstanceTests.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the MinioInstance aggregate. This tracks adopted MinIO deployments
|
||||
/// that the platform uses as backup targets for CNPG clusters.
|
||||
/// </summary>
|
||||
public class MinioInstanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Adopt_WithValidInputs_CreatesInstanceInRunningState()
|
||||
{
|
||||
// Arrange & Act — Adopt an existing MinIO deployment discovered on the cluster.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
MinioInstance minio = MinioInstance.Adopt(
|
||||
clusterId: clusterId,
|
||||
name: "platform-minio",
|
||||
ns: "minio-system",
|
||||
endpoint: "minio.minio-system.svc:9000",
|
||||
consoleEndpoint: "minio-console.minio-system.svc:9001",
|
||||
credentialsSecret: "minio-root-credentials",
|
||||
totalCapacity: "500Gi",
|
||||
usedCapacity: "120Gi",
|
||||
buckets: new List<string> { "cnpg-backups", "loki-logs", "velero" });
|
||||
|
||||
// Assert — Adopted instances are already running.
|
||||
|
||||
minio.Id.Should().NotBe(Guid.Empty);
|
||||
minio.ClusterId.Should().Be(clusterId);
|
||||
minio.Name.Should().Be("platform-minio");
|
||||
minio.Namespace.Should().Be("minio-system");
|
||||
minio.Endpoint.Should().Be("minio.minio-system.svc:9000");
|
||||
minio.ConsoleEndpoint.Should().Be("minio-console.minio-system.svc:9001");
|
||||
minio.CredentialsSecret.Should().Be("minio-root-credentials");
|
||||
minio.Status.Should().Be(MinioInstanceStatus.Running);
|
||||
minio.Buckets.Should().HaveCount(3);
|
||||
minio.Buckets.Should().Contain("cnpg-backups");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adopt_WithEmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => MinioInstance.Adopt(
|
||||
Guid.NewGuid(), "", "ns", "endpoint:9000", null,
|
||||
"secret", "500Gi", null, null);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adopt_WithEmptyEndpoint_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => MinioInstance.Adopt(
|
||||
Guid.NewGuid(), "minio", "ns", "", null,
|
||||
"secret", "500Gi", null, null);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("endpoint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adopt_WithEmptyCredentials_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => MinioInstance.Adopt(
|
||||
Guid.NewGuid(), "minio", "ns", "endpoint:9000", null,
|
||||
"", "500Gi", null, null);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("credentialsSecret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBucket_WhenRunning_AddsBucketToList()
|
||||
{
|
||||
// Arrange — A running MinIO instance.
|
||||
|
||||
MinioInstance minio = CreateTestInstance();
|
||||
|
||||
// Act — CNPG provisioning requests a new bucket for WAL storage.
|
||||
|
||||
EntKube.SharedKernel.Domain.Result<string> result = minio.CreateBucket("new-pg-backups");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
minio.Buckets.Should().Contain("new-pg-backups");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBucket_DuplicateName_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
MinioInstance minio = MinioInstance.Adopt(
|
||||
Guid.NewGuid(), "minio", "ns", "minio:9000", null,
|
||||
"secret", "500Gi", null,
|
||||
new List<string> { "existing-bucket" });
|
||||
|
||||
// Act
|
||||
|
||||
EntKube.SharedKernel.Domain.Result<string> result = minio.CreateBucket("existing-bucket");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBucket_WhenDegraded_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A degraded MinIO can't serve new bucket requests.
|
||||
|
||||
MinioInstance minio = CreateTestInstance();
|
||||
minio.MarkDegraded("Storage pool full");
|
||||
|
||||
// Act
|
||||
|
||||
EntKube.SharedKernel.Domain.Result<string> result = minio.CreateBucket("new-bucket");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDegraded_SetsStatusAndMessage()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
MinioInstance minio = CreateTestInstance();
|
||||
|
||||
// Act
|
||||
|
||||
minio.MarkDegraded("Drive offline");
|
||||
|
||||
// Assert
|
||||
|
||||
minio.Status.Should().Be(MinioInstanceStatus.Degraded);
|
||||
minio.StatusMessage.Should().Be("Drive offline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkRunning_ClearsDegradedStatus()
|
||||
{
|
||||
// Arrange — A previously degraded instance recovers.
|
||||
|
||||
MinioInstance minio = CreateTestInstance();
|
||||
minio.MarkDegraded("Temporary issue");
|
||||
|
||||
// Act
|
||||
|
||||
minio.MarkRunning();
|
||||
|
||||
// Assert
|
||||
|
||||
minio.Status.Should().Be(MinioInstanceStatus.Running);
|
||||
minio.StatusMessage.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBackupCredentials_ReturnsEndpointAndSecret()
|
||||
{
|
||||
// Arrange — CNPG needs to know where to send backups.
|
||||
|
||||
MinioInstance minio = MinioInstance.Adopt(
|
||||
Guid.NewGuid(), "minio", "minio-system", "minio.minio-system.svc:9000", null,
|
||||
"minio-root-credentials", "1Ti", null, null);
|
||||
|
||||
// Act
|
||||
|
||||
MinioBackupTarget target = minio.GetBackupTarget("cnpg-backups");
|
||||
|
||||
// Assert
|
||||
|
||||
target.Endpoint.Should().Be("minio.minio-system.svc:9000");
|
||||
target.Bucket.Should().Be("cnpg-backups");
|
||||
target.CredentialsSecret.Should().Be("minio-root-credentials");
|
||||
}
|
||||
|
||||
private static MinioInstance CreateTestInstance()
|
||||
{
|
||||
return MinioInstance.Adopt(
|
||||
Guid.NewGuid(), "platform-minio", "minio-system",
|
||||
"minio.minio-system.svc:9000", "minio-console:9001",
|
||||
"minio-root-credentials", "500Gi", "120Gi",
|
||||
new List<string> { "cnpg-backups" });
|
||||
}
|
||||
}
|
||||
307
tests/EntKube.Provisioning.Tests/Domain/MinioTenantTests.cs
Normal file
307
tests/EntKube.Provisioning.Tests/Domain/MinioTenantTests.cs
Normal file
@@ -0,0 +1,307 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the MinioTenant aggregate. A MinIO Tenant represents an actual storage
|
||||
/// cluster managed by the MinIO Operator on Kubernetes. Each tenant has one or more
|
||||
/// pools, each pool defining how many servers and disks to use.
|
||||
///
|
||||
/// One MinIO Operator installation can host multiple tenants — each tenant is a
|
||||
/// separate storage cluster with its own credentials, namespace, and capacity.
|
||||
/// </summary>
|
||||
public class MinioTenantTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_WithValidInputs_CreatesTenantInProvisioningState()
|
||||
{
|
||||
// Arrange — A platform admin wants to provision a new MinIO storage tenant
|
||||
// on a cluster. They specify how much capacity they need: 4 servers, each
|
||||
// with 4 disks of 100Gi.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
List<MinioTenantPool> pools = new()
|
||||
{
|
||||
new MinioTenantPool(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
MinioTenant tenant = MinioTenant.Create(
|
||||
clusterId: clusterId,
|
||||
name: "platform-storage",
|
||||
ns: "minio-tenant-1",
|
||||
pools: pools);
|
||||
|
||||
// Assert — New tenants start in Provisioning state until the operator confirms.
|
||||
|
||||
tenant.Id.Should().NotBe(Guid.Empty);
|
||||
tenant.ClusterId.Should().Be(clusterId);
|
||||
tenant.Name.Should().Be("platform-storage");
|
||||
tenant.Namespace.Should().Be("minio-tenant-1");
|
||||
tenant.Status.Should().Be(MinioTenantStatus.Provisioning);
|
||||
tenant.Pools.Should().HaveCount(1);
|
||||
tenant.Pools[0].Servers.Should().Be(4);
|
||||
tenant.Pools[0].VolumesPerServer.Should().Be(4);
|
||||
tenant.Pools[0].StorageSize.Should().Be("100Gi");
|
||||
tenant.Pools[0].StorageClass.Should().Be("ceph-block");
|
||||
tenant.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithMultiplePools_TracksAllPools()
|
||||
{
|
||||
// Arrange — Some deployments need heterogeneous pools:
|
||||
// one for hot data (fast NVMe) and one for warm data (spinning disk).
|
||||
|
||||
List<MinioTenantPool> pools = new()
|
||||
{
|
||||
new MinioTenantPool(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "nvme-fast"),
|
||||
new MinioTenantPool(Servers: 2, VolumesPerServer: 8, StorageSize: "500Gi", StorageClass: "hdd-bulk")
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
MinioTenant tenant = MinioTenant.Create(Guid.NewGuid(), "multi-pool", "minio-mp", pools);
|
||||
|
||||
// Assert
|
||||
|
||||
tenant.Pools.Should().HaveCount(2);
|
||||
tenant.Pools[0].StorageClass.Should().Be("nvme-fast");
|
||||
tenant.Pools[1].StorageClass.Should().Be("hdd-bulk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
List<MinioTenantPool> pools = new() { new MinioTenantPool(4, 4, "100Gi", "standard") };
|
||||
|
||||
Action act = () => MinioTenant.Create(Guid.NewGuid(), "", "ns", pools);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyNamespace_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
List<MinioTenantPool> pools = new() { new MinioTenantPool(4, 4, "100Gi", "standard") };
|
||||
|
||||
Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "", pools);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("ns");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithNoPools_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — A tenant must have at least one pool to be meaningful.
|
||||
|
||||
Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "ns", new List<MinioTenantPool>());
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("pools");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithZeroServers_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — Each pool needs at least one server.
|
||||
|
||||
List<MinioTenantPool> pools = new() { new MinioTenantPool(0, 4, "100Gi", "standard") };
|
||||
|
||||
Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "ns", pools);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithZeroVolumes_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — Each pool needs at least one volume per server.
|
||||
|
||||
List<MinioTenantPool> pools = new() { new MinioTenantPool(4, 0, "100Gi", "standard") };
|
||||
|
||||
Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "ns", pools);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkRunning_TransitionsFromProvisioning()
|
||||
{
|
||||
// Arrange — After the operator creates the tenant, the reconciler confirms it's running.
|
||||
|
||||
MinioTenant tenant = CreateTestTenant();
|
||||
|
||||
// Act
|
||||
|
||||
tenant.MarkRunning();
|
||||
|
||||
// Assert
|
||||
|
||||
tenant.Status.Should().Be(MinioTenantStatus.Running);
|
||||
tenant.StatusMessage.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDegraded_SetsStatusAndReason()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
MinioTenant tenant = CreateTestTenant();
|
||||
tenant.MarkRunning();
|
||||
|
||||
// Act
|
||||
|
||||
tenant.MarkDegraded("1 drive offline");
|
||||
|
||||
// Assert
|
||||
|
||||
tenant.Status.Should().Be(MinioTenantStatus.Degraded);
|
||||
tenant.StatusMessage.Should().Be("1 drive offline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePools_ReplacesPoolConfiguration()
|
||||
{
|
||||
// Arrange — The admin wants to scale out: add more servers to the pool.
|
||||
|
||||
MinioTenant tenant = CreateTestTenant();
|
||||
tenant.MarkRunning();
|
||||
|
||||
List<MinioTenantPool> newPools = new()
|
||||
{
|
||||
new MinioTenantPool(Servers: 8, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
tenant.UpdatePools(newPools);
|
||||
|
||||
// Assert — Status goes back to Provisioning while the operator reconciles.
|
||||
|
||||
tenant.Pools.Should().HaveCount(1);
|
||||
tenant.Pools[0].Servers.Should().Be(8);
|
||||
tenant.Status.Should().Be(MinioTenantStatus.Provisioning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePools_WithEmptyPools_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
MinioTenant tenant = CreateTestTenant();
|
||||
tenant.MarkRunning();
|
||||
|
||||
// Act & Assert
|
||||
|
||||
Action act = () => tenant.UpdatePools(new List<MinioTenantPool>());
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decommission_MarksAsDecommissioned()
|
||||
{
|
||||
// Arrange — The admin tears down a tenant. The operator will delete resources.
|
||||
|
||||
MinioTenant tenant = CreateTestTenant();
|
||||
tenant.MarkRunning();
|
||||
|
||||
// Act
|
||||
|
||||
tenant.Decommission();
|
||||
|
||||
// Assert
|
||||
|
||||
tenant.Status.Should().Be(MinioTenantStatus.Decommissioned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEndpoint_ReturnsConventionalServiceUrl()
|
||||
{
|
||||
// Arrange — MinIO tenants expose storage at <name>-hl.<namespace>.svc:9000
|
||||
|
||||
MinioTenant tenant = CreateTestTenant();
|
||||
|
||||
// Act
|
||||
|
||||
string endpoint = tenant.GetEndpoint();
|
||||
|
||||
// Assert
|
||||
|
||||
endpoint.Should().Be("platform-storage-hl.minio-tenant-1.svc:9000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConsoleEndpoint_ReturnsConventionalConsoleUrl()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
MinioTenant tenant = CreateTestTenant();
|
||||
|
||||
// Act
|
||||
|
||||
string consoleEndpoint = tenant.GetConsoleEndpoint();
|
||||
|
||||
// Assert
|
||||
|
||||
consoleEndpoint.Should().Be("platform-storage-console.minio-tenant-1.svc:9443");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCredentialsSecretName_ReturnsConventionalSecretName()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
MinioTenant tenant = CreateTestTenant();
|
||||
|
||||
// Act
|
||||
|
||||
string secret = tenant.GetCredentialsSecretName();
|
||||
|
||||
// Assert
|
||||
|
||||
secret.Should().Be("platform-storage-secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalCapacity_CalculatesFromPoolsAsString()
|
||||
{
|
||||
// Arrange — Capacity = servers * volumesPerServer * storageSize (per pool).
|
||||
// With 4 servers × 4 volumes × 100Gi = 1600Gi total.
|
||||
|
||||
MinioTenant tenant = CreateTestTenant();
|
||||
|
||||
// Act
|
||||
|
||||
string capacity = tenant.CalculateTotalCapacity();
|
||||
|
||||
// Assert — 4 × 4 × 100 = 1600Gi
|
||||
|
||||
capacity.Should().Be("1600Gi");
|
||||
}
|
||||
|
||||
private static MinioTenant CreateTestTenant()
|
||||
{
|
||||
List<MinioTenantPool> pools = new()
|
||||
{
|
||||
new MinioTenantPool(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
|
||||
};
|
||||
|
||||
return MinioTenant.Create(Guid.NewGuid(), "platform-storage", "minio-tenant-1", pools);
|
||||
}
|
||||
}
|
||||
405
tests/EntKube.Provisioning.Tests/Domain/MongoClusterTests.cs
Normal file
405
tests/EntKube.Provisioning.Tests/Domain/MongoClusterTests.cs
Normal file
@@ -0,0 +1,405 @@
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the MongoCluster aggregate root. This rich domain model tracks
|
||||
/// everything about a MongoDB Community Operator cluster: replica set members,
|
||||
/// storage, backup configuration, databases, users, and version lifecycle.
|
||||
///
|
||||
/// MongoDB Community Operator manages MongoDBCommunity CRDs on Kubernetes.
|
||||
/// Each cluster is a replica set with configurable members, storage, and
|
||||
/// authentication. Backup is handled via scheduled mongodump jobs to MinIO.
|
||||
/// </summary>
|
||||
public class MongoClusterTests
|
||||
{
|
||||
// ─── Creation ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_WithValidInputs_ReturnsClusterInProvisioningState()
|
||||
{
|
||||
// Arrange & Act — A platform admin provisions a new MongoDB cluster
|
||||
// for shared use on a specific Kubernetes cluster.
|
||||
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
|
||||
environmentId: environmentId,
|
||||
clusterId: clusterId,
|
||||
name: "platform-mongo",
|
||||
ns: "mongodb-system",
|
||||
mongoVersion: "7.0.12",
|
||||
members: 3,
|
||||
storageSize: "50Gi",
|
||||
minioEndpoint: "minio.minio-system.svc:9000",
|
||||
minioBucket: "mongo-backups",
|
||||
minioCredentialsSecret: "minio-mongo-credentials");
|
||||
|
||||
// Assert — The cluster starts in Provisioning state, waiting for the
|
||||
// reconciler to create the MongoDBCommunity resource.
|
||||
|
||||
mongoCluster.Id.Should().NotBe(Guid.Empty);
|
||||
mongoCluster.EnvironmentId.Should().Be(environmentId);
|
||||
mongoCluster.ClusterId.Should().Be(clusterId);
|
||||
mongoCluster.Name.Should().Be("platform-mongo");
|
||||
mongoCluster.Namespace.Should().Be("mongodb-system");
|
||||
mongoCluster.MongoVersion.Should().Be("7.0.12");
|
||||
mongoCluster.Members.Should().Be(3);
|
||||
mongoCluster.StorageSize.Should().Be("50Gi");
|
||||
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Provisioning);
|
||||
mongoCluster.Databases.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithMinIOBackupConfig_SetsBackupConfiguration()
|
||||
{
|
||||
// Arrange & Act — Backup to MinIO is configured at creation time
|
||||
// for mongodump-based backups. Unlike CNPG WAL archiving, MongoDB
|
||||
// uses periodic full dumps.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
|
||||
environmentId: Guid.NewGuid(),
|
||||
clusterId: Guid.NewGuid(),
|
||||
name: "prod-mongo",
|
||||
ns: "mongodb-prod",
|
||||
mongoVersion: "7.0.12",
|
||||
members: 3,
|
||||
storageSize: "100Gi",
|
||||
minioEndpoint: "minio.minio-system.svc:9000",
|
||||
minioBucket: "mongo-dump-archive",
|
||||
minioCredentialsSecret: "minio-mongo-creds");
|
||||
|
||||
// Assert — Backup configuration is stored as part of the aggregate.
|
||||
|
||||
mongoCluster.BackupConfig.Should().NotBeNull();
|
||||
mongoCluster.BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000");
|
||||
mongoCluster.BackupConfig.Bucket.Should().Be("mongo-dump-archive");
|
||||
mongoCluster.BackupConfig.CredentialsSecret.Should().Be("minio-mongo-creds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — The domain rejects invalid cluster names.
|
||||
|
||||
Action act = () => Provisioning.Domain.MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "", "ns", "7.0.12", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithZeroMembers_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — Must have at least 1 replica set member.
|
||||
|
||||
Action act = () => Provisioning.Domain.MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "7.0.12", 0, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("members");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyMinioEndpoint_CreatesClusterWithoutBackup()
|
||||
{
|
||||
// Arrange & Act — Backup is optional. When MinIO endpoint is empty,
|
||||
// the cluster should be created successfully without backup config.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "7.0.12", 3, "50Gi",
|
||||
"", "bucket", "secret");
|
||||
|
||||
// Assert — cluster exists but has no backup configuration.
|
||||
|
||||
mongoCluster.Should().NotBeNull();
|
||||
mongoCluster.BackupConfig.Should().BeNull();
|
||||
}
|
||||
|
||||
// ─── Adoption ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Adopt_FromExistingCluster_CreatesRunningClusterWithDiscoveredConfig()
|
||||
{
|
||||
// Arrange & Act — When we adopt an existing MongoDBCommunity cluster
|
||||
// that's already running on K8s, we create it in Running state.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Adopt(
|
||||
environmentId: Guid.NewGuid(),
|
||||
clusterId: Guid.NewGuid(),
|
||||
name: "existing-mongo",
|
||||
ns: "mongodb-system",
|
||||
mongoVersion: "6.0.16",
|
||||
members: 2,
|
||||
storageSize: "20Gi",
|
||||
minioEndpoint: "minio.minio-system.svc:9000",
|
||||
minioBucket: "existing-backups",
|
||||
minioCredentialsSecret: "existing-minio-creds",
|
||||
databases: new List<string> { "app_db", "analytics_db" });
|
||||
|
||||
// Assert — Adopted clusters start in Running state and include
|
||||
// any databases that were already present.
|
||||
|
||||
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Running);
|
||||
mongoCluster.Databases.Should().HaveCount(2);
|
||||
mongoCluster.Databases.Should().Contain("app_db");
|
||||
mongoCluster.Databases.Should().Contain("analytics_db");
|
||||
}
|
||||
|
||||
// ─── Status transitions ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MarkRunning_TransitionsFromProvisioning()
|
||||
{
|
||||
// Arrange — A freshly created cluster in Provisioning state.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
|
||||
|
||||
// Act — The reconciler confirmed the cluster is ready.
|
||||
|
||||
mongoCluster.MarkRunning();
|
||||
|
||||
// Assert
|
||||
|
||||
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Running);
|
||||
mongoCluster.LastReconcileAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDegraded_SetsStatusToDegraded()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
|
||||
mongoCluster.MarkRunning();
|
||||
|
||||
// Act — Reconciler detected an issue.
|
||||
|
||||
mongoCluster.MarkDegraded("Replica set member not ready");
|
||||
|
||||
// Assert
|
||||
|
||||
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Degraded);
|
||||
mongoCluster.StatusMessage.Should().Be("Replica set member not ready");
|
||||
}
|
||||
|
||||
// ─── Database management ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AddDatabase_WhenRunning_AddsDatabaseToList()
|
||||
{
|
||||
// Arrange — A running cluster can have databases added.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
|
||||
mongoCluster.MarkRunning();
|
||||
|
||||
// Act — An application requests a new database.
|
||||
|
||||
Result<string> result = mongoCluster.AddDatabase("my_app_db", "app_user");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
mongoCluster.Databases.Should().Contain("my_app_db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDatabase_WhenNotRunning_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A cluster still provisioning can't accept new databases.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = mongoCluster.AddDatabase("my_db", "user");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDatabase_DuplicateName_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A running cluster with one database already.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
|
||||
mongoCluster.MarkRunning();
|
||||
mongoCluster.AddDatabase("existing_db", "user1");
|
||||
|
||||
// Act — Try to add a database with the same name.
|
||||
|
||||
Result<string> result = mongoCluster.AddDatabase("existing_db", "user2");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("already exists");
|
||||
}
|
||||
|
||||
// ─── Version upgrades ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RequestUpgrade_WhenRunning_SetsUpgradingStatusAndTargetVersion()
|
||||
{
|
||||
// Arrange — A running cluster at version 6.0.16.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "6.0.16", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
mongoCluster.MarkRunning();
|
||||
|
||||
// Act — Platform admin requests upgrade to 7.0.12.
|
||||
|
||||
Result<string> result = mongoCluster.RequestUpgrade("7.0.12");
|
||||
|
||||
// Assert — The cluster transitions to Upgrading state.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Upgrading);
|
||||
mongoCluster.TargetVersion.Should().Be("7.0.12");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestUpgrade_WhenNotRunning_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A cluster still provisioning can't be upgraded.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = mongoCluster.RequestUpgrade("7.0.12");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestUpgrade_ToSameVersion_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A cluster already at version 7.0.12.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "7.0.12", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
mongoCluster.MarkRunning();
|
||||
|
||||
// Act — Try to upgrade to the same version.
|
||||
|
||||
Result<string> result = mongoCluster.RequestUpgrade("7.0.12");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("same version");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteUpgrade_TransitionsToRunningWithNewVersion()
|
||||
{
|
||||
// Arrange — A cluster mid-upgrade.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "6.0.16", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
mongoCluster.MarkRunning();
|
||||
mongoCluster.RequestUpgrade("7.0.12");
|
||||
|
||||
// Act — Reconciler confirmed upgrade completed.
|
||||
|
||||
mongoCluster.CompleteUpgrade();
|
||||
|
||||
// Assert — Version updated, status back to Running.
|
||||
|
||||
mongoCluster.MongoVersion.Should().Be("7.0.12");
|
||||
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Running);
|
||||
mongoCluster.TargetVersion.Should().BeNull();
|
||||
}
|
||||
|
||||
// ─── Scaling ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ScaleMembers_UpdatesMemberCount()
|
||||
{
|
||||
// Arrange — A running 3-member cluster.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
|
||||
mongoCluster.MarkRunning();
|
||||
|
||||
// Act — Scale up to 5 members for more redundancy.
|
||||
|
||||
Result<string> result = mongoCluster.ScaleMembers(5);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
mongoCluster.Members.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScaleMembers_ToZero_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
|
||||
mongoCluster.MarkRunning();
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = mongoCluster.ScaleMembers(0);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("at least 1");
|
||||
}
|
||||
|
||||
// ─── Backup configuration ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ConfigureBackupSchedule_UpdatesRetentionPolicy()
|
||||
{
|
||||
// Arrange — A running cluster needs its backup schedule updated.
|
||||
|
||||
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
|
||||
mongoCluster.MarkRunning();
|
||||
|
||||
// Act — Set backup to run every 6 hours with 30-day retention.
|
||||
|
||||
mongoCluster.ConfigureBackupSchedule("0 */6 * * *", retentionDays: 30);
|
||||
|
||||
// Assert
|
||||
|
||||
mongoCluster.BackupConfig!.Schedule.Should().Be("0 */6 * * *");
|
||||
mongoCluster.BackupConfig.RetentionDays.Should().Be(30);
|
||||
}
|
||||
|
||||
// ─── Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Provisioning.Domain.MongoCluster CreateTestCluster()
|
||||
{
|
||||
return Provisioning.Domain.MongoCluster.Create(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"test-mongo",
|
||||
"mongodb-system",
|
||||
"7.0.12",
|
||||
3,
|
||||
"50Gi",
|
||||
"minio.minio-system.svc:9000",
|
||||
"mongo-backups",
|
||||
"minio-mongo-credentials");
|
||||
}
|
||||
}
|
||||
437
tests/EntKube.Provisioning.Tests/Domain/PostgresClusterTests.cs
Normal file
437
tests/EntKube.Provisioning.Tests/Domain/PostgresClusterTests.cs
Normal file
@@ -0,0 +1,437 @@
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the PostgresCluster aggregate root. This rich domain model tracks
|
||||
/// everything about a CloudNativePG PostgreSQL cluster: instances, storage,
|
||||
/// WAL archiving, backup configuration, databases, and version lifecycle.
|
||||
/// </summary>
|
||||
public class PostgresClusterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_WithValidInputs_ReturnsClusterInProvisioningState()
|
||||
{
|
||||
// Arrange & Act — A platform admin provisions a new PostgreSQL cluster
|
||||
// for shared use on a specific Kubernetes cluster.
|
||||
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
|
||||
environmentId: environmentId,
|
||||
clusterId: clusterId,
|
||||
name: "platform-db",
|
||||
ns: "cnpg-system",
|
||||
postgresVersion: "16.4",
|
||||
instances: 3,
|
||||
storageSize: "50Gi",
|
||||
minioEndpoint: "minio.minio-system.svc:9000",
|
||||
minioBucket: "cnpg-backups",
|
||||
minioCredentialsSecret: "minio-cnpg-credentials");
|
||||
|
||||
// Assert — The cluster starts in Provisioning state, waiting for the
|
||||
// reconciler to actually create the CNPG Cluster resource.
|
||||
|
||||
pgCluster.Id.Should().NotBe(Guid.Empty);
|
||||
pgCluster.EnvironmentId.Should().Be(environmentId);
|
||||
pgCluster.ClusterId.Should().Be(clusterId);
|
||||
pgCluster.Name.Should().Be("platform-db");
|
||||
pgCluster.Namespace.Should().Be("cnpg-system");
|
||||
pgCluster.PostgresVersion.Should().Be("16.4");
|
||||
pgCluster.Instances.Should().Be(3);
|
||||
pgCluster.StorageSize.Should().Be("50Gi");
|
||||
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Provisioning);
|
||||
pgCluster.Databases.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithMinIOBackupConfig_SetsWalArchiving()
|
||||
{
|
||||
// Arrange & Act — WAL archiving to MinIO is configured at creation time
|
||||
// since it's a hard dependency. The cluster won't be created without it.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
|
||||
environmentId: Guid.NewGuid(),
|
||||
clusterId: Guid.NewGuid(),
|
||||
name: "prod-db",
|
||||
ns: "cnpg-prod",
|
||||
postgresVersion: "16.4",
|
||||
instances: 3,
|
||||
storageSize: "100Gi",
|
||||
minioEndpoint: "minio.minio-system.svc:9000",
|
||||
minioBucket: "pg-wal-archive",
|
||||
minioCredentialsSecret: "minio-pg-creds");
|
||||
|
||||
// Assert — Backup configuration is stored as part of the aggregate.
|
||||
|
||||
pgCluster.BackupConfig.Should().NotBeNull();
|
||||
pgCluster.BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000");
|
||||
pgCluster.BackupConfig.Bucket.Should().Be("pg-wal-archive");
|
||||
pgCluster.BackupConfig.CredentialsSecret.Should().Be("minio-pg-creds");
|
||||
pgCluster.BackupConfig.WalArchivingEnabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — The domain rejects invalid cluster names.
|
||||
|
||||
Action act = () => Provisioning.Domain.PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "", "ns", "16.4", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithZeroInstances_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — Must have at least 1 instance.
|
||||
|
||||
Action act = () => Provisioning.Domain.PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 0, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("instances");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyMinioEndpoint_CreatesClusterWithoutBackup()
|
||||
{
|
||||
// Arrange & Act — Backup is optional. When MinIO endpoint is empty,
|
||||
// the cluster should be created successfully without backup config.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 3, "50Gi",
|
||||
"", "bucket", "secret");
|
||||
|
||||
// Assert — cluster exists but has no backup configuration.
|
||||
|
||||
pgCluster.Should().NotBeNull();
|
||||
pgCluster.BackupConfig.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adopt_FromExistingCluster_CreatesRunningClusterWithDiscoveredConfig()
|
||||
{
|
||||
// Arrange & Act — When we adopt an existing CNPG cluster that's already
|
||||
// running on the K8s cluster, we create it in Running state with the
|
||||
// discovered configuration.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Adopt(
|
||||
environmentId: Guid.NewGuid(),
|
||||
clusterId: Guid.NewGuid(),
|
||||
name: "existing-db",
|
||||
ns: "cnpg-system",
|
||||
postgresVersion: "15.6",
|
||||
instances: 2,
|
||||
storageSize: "20Gi",
|
||||
minioEndpoint: "minio.minio-system.svc:9000",
|
||||
minioBucket: "existing-backups",
|
||||
minioCredentialsSecret: "existing-minio-creds",
|
||||
databases: new List<string> { "app_db", "analytics_db" });
|
||||
|
||||
// Assert — Adopted clusters start in Running state and include
|
||||
// any databases that were already present.
|
||||
|
||||
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running);
|
||||
pgCluster.Databases.Should().HaveCount(2);
|
||||
pgCluster.Databases.Should().Contain("app_db");
|
||||
pgCluster.Databases.Should().Contain("analytics_db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkRunning_TransitionsFromProvisioning()
|
||||
{
|
||||
// Arrange — A freshly created cluster in Provisioning state.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
|
||||
|
||||
// Act — The reconciler confirmed the cluster is ready.
|
||||
|
||||
pgCluster.MarkRunning();
|
||||
|
||||
// Assert
|
||||
|
||||
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running);
|
||||
pgCluster.LastReconcileAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDatabase_WhenRunning_AddsDatabaseToList()
|
||||
{
|
||||
// Arrange — A running cluster can have databases added to it.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
|
||||
pgCluster.MarkRunning();
|
||||
|
||||
// Act — An application requests a new database be provisioned.
|
||||
|
||||
Result<string> result = pgCluster.AddDatabase("my_app_db", "app_user");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
pgCluster.Databases.Should().Contain("my_app_db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDatabase_WhenNotRunning_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A cluster that's still provisioning can't accept new databases.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = pgCluster.AddDatabase("my_db", "user");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDatabase_DuplicateName_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A running cluster with one database already.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
|
||||
pgCluster.MarkRunning();
|
||||
pgCluster.AddDatabase("existing_db", "user1");
|
||||
|
||||
// Act — Try to add a database with the same name.
|
||||
|
||||
Result<string> result = pgCluster.AddDatabase("existing_db", "user2");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestUpgrade_WhenRunning_SetsUpgradingStatusAndTargetVersion()
|
||||
{
|
||||
// Arrange — A running cluster at version 15.6.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "15.6", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
pgCluster.MarkRunning();
|
||||
|
||||
// Act — Platform admin requests upgrade to 16.4.
|
||||
|
||||
Result<string> result = pgCluster.RequestUpgrade("16.4");
|
||||
|
||||
// Assert — The cluster transitions to Upgrading state with the target version recorded.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Upgrading);
|
||||
pgCluster.TargetVersion.Should().Be("16.4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestUpgrade_WhenNotRunning_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A cluster still provisioning can't be upgraded.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = pgCluster.RequestUpgrade("16.4");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestUpgrade_ToSameVersion_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A cluster already at version 16.4.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
pgCluster.MarkRunning();
|
||||
|
||||
// Act — Try to upgrade to the same version.
|
||||
|
||||
Result<string> result = pgCluster.RequestUpgrade("16.4");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("same version");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteUpgrade_TransitionsToRunningWithNewVersion()
|
||||
{
|
||||
// Arrange — A cluster that's mid-upgrade.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "15.6", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
pgCluster.MarkRunning();
|
||||
pgCluster.RequestUpgrade("16.4");
|
||||
|
||||
// Act — The reconciler confirmed the upgrade completed.
|
||||
|
||||
pgCluster.CompleteUpgrade();
|
||||
|
||||
// Assert — Version updated, status back to Running.
|
||||
|
||||
pgCluster.PostgresVersion.Should().Be("16.4");
|
||||
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running);
|
||||
pgCluster.TargetVersion.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScaleInstances_UpdatesInstanceCount()
|
||||
{
|
||||
// Arrange — A running 3-instance cluster.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
|
||||
pgCluster.MarkRunning();
|
||||
|
||||
// Act — Scale up to 5 instances for more read replicas.
|
||||
|
||||
Result<string> result = pgCluster.ScaleInstances(5);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
pgCluster.Instances.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScaleInstances_ToZero_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
|
||||
pgCluster.MarkRunning();
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = pgCluster.ScaleInstances(0);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("at least 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigureBackupSchedule_UpdatesRetentionPolicy()
|
||||
{
|
||||
// Arrange — A running cluster needs its backup schedule updated.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
|
||||
pgCluster.MarkRunning();
|
||||
|
||||
// Act — Set backup to run every 6 hours with 30-day retention.
|
||||
|
||||
pgCluster.ConfigureBackupSchedule("0 */6 * * *", retentionDays: 30);
|
||||
|
||||
// Assert
|
||||
|
||||
pgCluster.BackupConfig!.Schedule.Should().Be("0 */6 * * *");
|
||||
pgCluster.BackupConfig.RetentionDays.Should().Be(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDegraded_SetsStatusToDegraded()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
|
||||
pgCluster.MarkRunning();
|
||||
|
||||
// Act — Reconciler detected an issue.
|
||||
|
||||
pgCluster.MarkDegraded("Primary pod not ready");
|
||||
|
||||
// Assert
|
||||
|
||||
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Degraded);
|
||||
pgCluster.StatusMessage.Should().Be("Primary pod not ready");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithS3Region_StoresRegionInBackupConfig()
|
||||
{
|
||||
// Arrange & Act — When creating a CNPG cluster backed by an external S3
|
||||
// provider like Cleura, the region must be captured so barman-cloud can
|
||||
// use it for AWS Signature V4 authentication.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
|
||||
environmentId: Guid.NewGuid(),
|
||||
clusterId: Guid.NewGuid(),
|
||||
name: "cleura-db",
|
||||
ns: "cnpg-prod",
|
||||
postgresVersion: "16.4",
|
||||
instances: 3,
|
||||
storageSize: "100Gi",
|
||||
minioEndpoint: "https://s3.cleura.cloud",
|
||||
minioBucket: "cnpg-backups",
|
||||
minioCredentialsSecret: "cnpg-s3-creds",
|
||||
s3Region: "eu-north-1");
|
||||
|
||||
// Assert — The region is stored in the backup configuration so it can
|
||||
// be included in the CNPG manifest's s3Credentials.region field.
|
||||
|
||||
pgCluster.BackupConfig.Should().NotBeNull();
|
||||
pgCluster.BackupConfig!.S3Region.Should().Be("eu-north-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithoutS3Region_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act — MinIO endpoints don't need a region. When omitted,
|
||||
// S3Region should be null and barman-cloud will skip region config.
|
||||
|
||||
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
|
||||
environmentId: Guid.NewGuid(),
|
||||
clusterId: Guid.NewGuid(),
|
||||
name: "minio-db",
|
||||
ns: "cnpg-system",
|
||||
postgresVersion: "16.4",
|
||||
instances: 3,
|
||||
storageSize: "50Gi",
|
||||
minioEndpoint: "minio.minio-system.svc:9000",
|
||||
minioBucket: "cnpg-backups",
|
||||
minioCredentialsSecret: "minio-creds");
|
||||
|
||||
// Assert
|
||||
|
||||
pgCluster.BackupConfig.Should().NotBeNull();
|
||||
pgCluster.BackupConfig!.S3Region.Should().BeNull();
|
||||
}
|
||||
|
||||
private static Provisioning.Domain.PostgresCluster CreateTestCluster()
|
||||
{
|
||||
return Provisioning.Domain.PostgresCluster.Create(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"test-db",
|
||||
"cnpg-system",
|
||||
"16.4",
|
||||
3,
|
||||
"50Gi",
|
||||
"minio.minio-system.svc:9000",
|
||||
"cnpg-backups",
|
||||
"minio-cnpg-credentials");
|
||||
}
|
||||
}
|
||||
275
tests/EntKube.Provisioning.Tests/Domain/PrometheusStackTests.cs
Normal file
275
tests/EntKube.Provisioning.Tests/Domain/PrometheusStackTests.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Domain;
|
||||
|
||||
public class PrometheusStackTests
|
||||
{
|
||||
private readonly Guid clusterId = Guid.NewGuid();
|
||||
private readonly Guid environmentId = Guid.NewGuid();
|
||||
|
||||
// ─── Creation ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_WithValidInputs_CreatesStackInPendingState()
|
||||
{
|
||||
// Arrange & Act — An operator provisions a new Prometheus stack on a cluster.
|
||||
// Prometheus is per-cluster: each cluster gets its own metrics collector.
|
||||
|
||||
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
|
||||
|
||||
// Assert — The stack should be in a Pending state, with sensible defaults
|
||||
// for Prometheus retention, replicas, and Alertmanager.
|
||||
|
||||
stack.Id.Should().NotBe(Guid.Empty);
|
||||
stack.ClusterId.Should().Be(clusterId);
|
||||
stack.EnvironmentId.Should().Be(environmentId);
|
||||
stack.Name.Should().Be("prod-prometheus");
|
||||
stack.Namespace.Should().Be("monitoring");
|
||||
stack.Status.Should().Be(PrometheusStackStatus.Pending);
|
||||
stack.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Prometheus defaults
|
||||
|
||||
stack.Prometheus.Should().NotBeNull();
|
||||
stack.Prometheus.Retention.Should().Be("30d");
|
||||
stack.Prometheus.Replicas.Should().Be(2);
|
||||
stack.Prometheus.StorageSize.Should().Be("50Gi");
|
||||
|
||||
// Alertmanager defaults
|
||||
|
||||
stack.Alertmanager.Should().NotBeNull();
|
||||
stack.Alertmanager.Enabled.Should().BeTrue();
|
||||
stack.Alertmanager.Replicas.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — A Prometheus stack must have a name.
|
||||
|
||||
Action act = () => PrometheusStack.Create(clusterId, environmentId, "", "monitoring");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyNamespace_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — A Prometheus stack must have a namespace.
|
||||
|
||||
Action act = () => PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("ns");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyClusterId_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — A Prometheus stack must belong to a cluster.
|
||||
|
||||
Action act = () => PrometheusStack.Create(Guid.Empty, environmentId, "prod-prometheus", "monitoring");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("clusterId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyEnvironmentId_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — A Prometheus stack must belong to an environment.
|
||||
|
||||
Action act = () => PrometheusStack.Create(clusterId, Guid.Empty, "prod-prometheus", "monitoring");
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("environmentId");
|
||||
}
|
||||
|
||||
// ─── Prometheus Configuration ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ConfigurePrometheus_UpdatesRetentionAndReplicas()
|
||||
{
|
||||
// Arrange — Start with a Prometheus stack using defaults.
|
||||
|
||||
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
|
||||
|
||||
// Act — The operator increases retention to 90 days and scales to 3 replicas.
|
||||
|
||||
stack.ConfigurePrometheus(retention: "90d", replicas: 3, storageSize: "100Gi", retentionSize: "90GB");
|
||||
|
||||
// Assert — Prometheus settings should reflect the new configuration.
|
||||
|
||||
stack.Prometheus.Retention.Should().Be("90d");
|
||||
stack.Prometheus.Replicas.Should().Be(3);
|
||||
stack.Prometheus.StorageSize.Should().Be("100Gi");
|
||||
stack.Prometheus.RetentionSize.Should().Be("90GB");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigurePrometheus_WithInvalidReplicas_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
|
||||
|
||||
// Act & Assert — Zero replicas doesn't make sense for Prometheus.
|
||||
|
||||
Action act = () => stack.ConfigurePrometheus(replicas: 0);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("replicas");
|
||||
}
|
||||
|
||||
// ─── Alertmanager Configuration ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ConfigureAlertmanager_UpdatesReplicasAndEnablement()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
|
||||
|
||||
// Act — The operator disables Alertmanager for a dev cluster.
|
||||
|
||||
stack.ConfigureAlertmanager(enabled: false, replicas: 1);
|
||||
|
||||
// Assert
|
||||
|
||||
stack.Alertmanager.Enabled.Should().BeFalse();
|
||||
stack.Alertmanager.Replicas.Should().Be(1);
|
||||
}
|
||||
|
||||
// ─── Ingress (Prometheus + Alertmanager) ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ConfigureIngress_SetsPrometheusAndAlertmanagerRouting()
|
||||
{
|
||||
// Arrange — Publish Prometheus and Alertmanager via Gateway API.
|
||||
|
||||
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
|
||||
|
||||
// Act
|
||||
|
||||
IngressConfiguration ingress = new(
|
||||
Enabled: true,
|
||||
Provider: IngressProvider.GatewayApi,
|
||||
PrometheusHostname: "prometheus.prod01.example.com",
|
||||
AlertmanagerHostname: "alertmanager.prod01.example.com",
|
||||
TlsEnabled: true,
|
||||
TlsCertificateMode: TlsCertificateMode.Manual,
|
||||
TlsSecretName: "monitoring-tls");
|
||||
|
||||
stack.ConfigureIngress(ingress);
|
||||
|
||||
// Assert
|
||||
|
||||
stack.Ingress.Should().NotBeNull();
|
||||
stack.Ingress!.Enabled.Should().BeTrue();
|
||||
stack.Ingress.Provider.Should().Be(IngressProvider.GatewayApi);
|
||||
stack.Ingress.PrometheusHostname.Should().Be("prometheus.prod01.example.com");
|
||||
stack.Ingress.AlertmanagerHostname.Should().Be("alertmanager.prod01.example.com");
|
||||
stack.Ingress.TlsEnabled.Should().BeTrue();
|
||||
stack.Ingress.TlsCertificateMode.Should().Be(TlsCertificateMode.Manual);
|
||||
stack.Ingress.TlsSecretName.Should().Be("monitoring-tls");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigureIngress_WithCertManager_DoesNotRequireSecretName()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
|
||||
|
||||
// Act — cert-manager with Let's Encrypt handles certificates automatically.
|
||||
|
||||
IngressConfiguration ingress = new(
|
||||
Enabled: true,
|
||||
Provider: IngressProvider.GatewayApi,
|
||||
PrometheusHostname: "prometheus.example.com",
|
||||
AlertmanagerHostname: "alertmanager.example.com",
|
||||
TlsEnabled: true,
|
||||
TlsCertificateMode: TlsCertificateMode.CertManager,
|
||||
TlsSecretName: null,
|
||||
ClusterIssuer: "letsencrypt-prod");
|
||||
|
||||
stack.ConfigureIngress(ingress);
|
||||
|
||||
// Assert
|
||||
|
||||
stack.Ingress!.TlsCertificateMode.Should().Be(TlsCertificateMode.CertManager);
|
||||
stack.Ingress.TlsSecretName.Should().BeNull();
|
||||
stack.Ingress.ClusterIssuer.Should().Be("letsencrypt-prod");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisableIngress_ClearsIngressConfig()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
|
||||
|
||||
IngressConfiguration ingress = new(
|
||||
Enabled: true,
|
||||
Provider: IngressProvider.GatewayApi,
|
||||
PrometheusHostname: "prometheus.example.com",
|
||||
AlertmanagerHostname: null,
|
||||
TlsEnabled: true,
|
||||
TlsCertificateMode: TlsCertificateMode.Manual,
|
||||
TlsSecretName: "tls-secret");
|
||||
|
||||
stack.ConfigureIngress(ingress);
|
||||
|
||||
// Act
|
||||
|
||||
stack.DisableIngress();
|
||||
|
||||
// Assert
|
||||
|
||||
stack.Ingress.Should().BeNull();
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MarkRunning_SetsStatusToRunning()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
|
||||
|
||||
// Act
|
||||
|
||||
stack.MarkRunning();
|
||||
|
||||
// Assert
|
||||
|
||||
stack.Status.Should().Be(PrometheusStackStatus.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDegraded_SetsStatusToDegraded()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
|
||||
|
||||
// Act
|
||||
|
||||
stack.MarkDegraded();
|
||||
|
||||
// Assert
|
||||
|
||||
stack.Status.Should().Be(PrometheusStackStatus.Degraded);
|
||||
}
|
||||
}
|
||||
305
tests/EntKube.Provisioning.Tests/Domain/RedisClusterTests.cs
Normal file
305
tests/EntKube.Provisioning.Tests/Domain/RedisClusterTests.cs
Normal file
@@ -0,0 +1,305 @@
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the RedisCluster aggregate root. This rich domain model tracks
|
||||
/// everything about a Redis instance managed by the OT-OPERATORS Redis Operator:
|
||||
/// replica count, storage, sentinel mode, persistence, and version lifecycle.
|
||||
///
|
||||
/// The OT-OPERATORS Redis Operator manages Redis, RedisCluster, RedisReplication,
|
||||
/// and RedisSentinel CRDs on Kubernetes. Each Redis instance can be standalone,
|
||||
/// replicated, or run with Sentinel for HA.
|
||||
/// </summary>
|
||||
public class RedisClusterTests
|
||||
{
|
||||
// ─── Creation ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_WithValidInputs_ReturnsClusterInProvisioningState()
|
||||
{
|
||||
// Arrange & Act — A platform admin provisions a new Redis instance
|
||||
// for shared caching on a specific Kubernetes cluster.
|
||||
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create(
|
||||
environmentId: environmentId,
|
||||
clusterId: clusterId,
|
||||
name: "platform-redis",
|
||||
ns: "redis",
|
||||
redisVersion: "7.2.5",
|
||||
replicas: 3,
|
||||
storageSize: "10Gi",
|
||||
sentinelEnabled: true);
|
||||
|
||||
// Assert — The cluster starts in Provisioning state, waiting for the
|
||||
// reconciler to create the Redis CR.
|
||||
|
||||
redisCluster.Id.Should().NotBe(Guid.Empty);
|
||||
redisCluster.EnvironmentId.Should().Be(environmentId);
|
||||
redisCluster.ClusterId.Should().Be(clusterId);
|
||||
redisCluster.Name.Should().Be("platform-redis");
|
||||
redisCluster.Namespace.Should().Be("redis");
|
||||
redisCluster.RedisVersion.Should().Be("7.2.5");
|
||||
redisCluster.Replicas.Should().Be(3);
|
||||
redisCluster.StorageSize.Should().Be("10Gi");
|
||||
redisCluster.SentinelEnabled.Should().BeTrue();
|
||||
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Provisioning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — The domain rejects invalid cluster names.
|
||||
|
||||
Action act = () => Provisioning.Domain.RedisCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "", "ns", "7.2.5", 3, "10Gi", true);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithZeroReplicas_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act — Must have at least 1 replica.
|
||||
|
||||
Action act = () => Provisioning.Domain.RedisCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.2.5", 0, "10Gi", true);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("replicas");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithEmptyNamespace_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => Provisioning.Domain.RedisCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "cache", "", "7.2.5", 3, "10Gi", true);
|
||||
|
||||
// Assert
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithParameterName("ns");
|
||||
}
|
||||
|
||||
// ─── Adoption ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Adopt_FromExistingInstance_CreatesRunningCluster()
|
||||
{
|
||||
// Arrange & Act — When we adopt an existing Redis instance that's
|
||||
// already running on the K8s cluster, we create it in Running state.
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Adopt(
|
||||
environmentId: Guid.NewGuid(),
|
||||
clusterId: Guid.NewGuid(),
|
||||
name: "existing-redis",
|
||||
ns: "redis",
|
||||
redisVersion: "7.0.15",
|
||||
replicas: 3,
|
||||
storageSize: "5Gi",
|
||||
sentinelEnabled: true);
|
||||
|
||||
// Assert — Adopted clusters start in Running state.
|
||||
|
||||
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Running);
|
||||
redisCluster.Name.Should().Be("existing-redis");
|
||||
redisCluster.LastReconcileAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// ─── Status transitions ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MarkRunning_TransitionsFromProvisioning()
|
||||
{
|
||||
// Arrange — A freshly created cluster in Provisioning state.
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster();
|
||||
|
||||
// Act — The reconciler confirmed the Redis instance is ready.
|
||||
|
||||
redisCluster.MarkRunning();
|
||||
|
||||
// Assert
|
||||
|
||||
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Running);
|
||||
redisCluster.LastReconcileAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkDegraded_SetsStatusToDegraded()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster();
|
||||
redisCluster.MarkRunning();
|
||||
|
||||
// Act — Reconciler detected an issue.
|
||||
|
||||
redisCluster.MarkDegraded("Replica pod not ready");
|
||||
|
||||
// Assert
|
||||
|
||||
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Degraded);
|
||||
redisCluster.StatusMessage.Should().Be("Replica pod not ready");
|
||||
}
|
||||
|
||||
// ─── Version upgrades ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RequestUpgrade_WhenRunning_SetsUpgradingStatusAndTargetVersion()
|
||||
{
|
||||
// Arrange — A running cluster at version 7.0.15.
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.0.15", 3, "10Gi", true);
|
||||
redisCluster.MarkRunning();
|
||||
|
||||
// Act — Platform admin requests upgrade to 7.2.5.
|
||||
|
||||
Result<string> result = redisCluster.RequestUpgrade("7.2.5");
|
||||
|
||||
// Assert — The cluster transitions to Upgrading state.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Upgrading);
|
||||
redisCluster.TargetVersion.Should().Be("7.2.5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestUpgrade_WhenNotRunning_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A cluster still provisioning can't be upgraded.
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster();
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = redisCluster.RequestUpgrade("7.2.5");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestUpgrade_ToSameVersion_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A cluster already at version 7.2.5.
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.2.5", 3, "10Gi", true);
|
||||
redisCluster.MarkRunning();
|
||||
|
||||
// Act — Try to upgrade to the same version.
|
||||
|
||||
Result<string> result = redisCluster.RequestUpgrade("7.2.5");
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("same version");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteUpgrade_TransitionsToRunningWithNewVersion()
|
||||
{
|
||||
// Arrange — A cluster mid-upgrade.
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.0.15", 3, "10Gi", true);
|
||||
redisCluster.MarkRunning();
|
||||
redisCluster.RequestUpgrade("7.2.5");
|
||||
|
||||
// Act — Reconciler confirmed upgrade completed.
|
||||
|
||||
redisCluster.CompleteUpgrade();
|
||||
|
||||
// Assert — Version updated, status back to Running.
|
||||
|
||||
redisCluster.RedisVersion.Should().Be("7.2.5");
|
||||
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Running);
|
||||
redisCluster.TargetVersion.Should().BeNull();
|
||||
}
|
||||
|
||||
// ─── Scaling ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ScaleReplicas_UpdatesReplicaCount()
|
||||
{
|
||||
// Arrange — A running 3-replica cluster.
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster();
|
||||
redisCluster.MarkRunning();
|
||||
|
||||
// Act — Scale up to 5 replicas for more read capacity.
|
||||
|
||||
Result<string> result = redisCluster.ScaleReplicas(5);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
redisCluster.Replicas.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScaleReplicas_ToZero_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster();
|
||||
redisCluster.MarkRunning();
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = redisCluster.ScaleReplicas(0);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("at least 1");
|
||||
}
|
||||
|
||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ConfigureSentinel_EnablesOrDisablesSentinel()
|
||||
{
|
||||
// Arrange — A cluster without sentinel.
|
||||
|
||||
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.2.5", 3, "10Gi", false);
|
||||
redisCluster.MarkRunning();
|
||||
|
||||
// Act — Enable sentinel for HA.
|
||||
|
||||
redisCluster.ConfigureSentinel(true);
|
||||
|
||||
// Assert
|
||||
|
||||
redisCluster.SentinelEnabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
// ─── Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Provisioning.Domain.RedisCluster CreateTestCluster()
|
||||
{
|
||||
return Provisioning.Domain.RedisCluster.Create(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"test-redis",
|
||||
"redis",
|
||||
"7.2.5",
|
||||
3,
|
||||
"10Gi",
|
||||
true);
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,20 @@ public class ServiceInstanceTests
|
||||
{
|
||||
// Arrange & Act — Request provisioning of a MinIO instance.
|
||||
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
ServiceInstance instance = ServiceInstance.Provision(
|
||||
clusterId,
|
||||
environmentId,
|
||||
ServiceType.MinIO,
|
||||
"tenant-storage",
|
||||
"minio-system");
|
||||
"minio-system",
|
||||
clusterId);
|
||||
|
||||
// Assert — Starts pending until the reconciler deploys it.
|
||||
|
||||
instance.Id.Should().NotBe(Guid.Empty);
|
||||
instance.EnvironmentId.Should().Be(environmentId);
|
||||
instance.ClusterId.Should().Be(clusterId);
|
||||
instance.ServiceType.Should().Be(ServiceType.MinIO);
|
||||
instance.Name.Should().Be("tenant-storage");
|
||||
@@ -34,7 +37,7 @@ public class ServiceInstanceTests
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
Action act = () => ServiceInstance.Provision(Guid.NewGuid(), ServiceType.MinIO, "", "ns");
|
||||
Action act = () => ServiceInstance.Provision(Guid.NewGuid(), ServiceType.MinIO, "", "ns", Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -47,7 +50,7 @@ public class ServiceInstanceTests
|
||||
{
|
||||
// Arrange
|
||||
|
||||
ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.CloudNativePG, "db", "cnpg-system");
|
||||
ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.CloudNativePG, "db", "cnpg-system", Guid.NewGuid());
|
||||
|
||||
// Act
|
||||
|
||||
@@ -64,7 +67,7 @@ public class ServiceInstanceTests
|
||||
{
|
||||
// Arrange
|
||||
|
||||
ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.Keycloak, "auth", "keycloak-system");
|
||||
ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.Keycloak, "auth", "keycloak-system", Guid.NewGuid());
|
||||
instance.MarkRunning();
|
||||
|
||||
// Act — Tenant admin decides to tear down this service.
|
||||
@@ -76,4 +79,48 @@ public class ServiceInstanceTests
|
||||
instance.DesiredState.Should().Be(ServiceState.Decommissioned);
|
||||
instance.CurrentState.Should().Be(ServiceState.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceType_IncludesGitea()
|
||||
{
|
||||
// Gitea is a shared service that can be provisioned on a cluster,
|
||||
// so ServiceType must include it as a valid option.
|
||||
|
||||
ServiceType type = ServiceType.Gitea;
|
||||
type.Should().BeDefined();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceType_IncludesHarbor()
|
||||
{
|
||||
// Harbor is a shared container registry that can be provisioned
|
||||
// on a cluster, so ServiceType must include it.
|
||||
|
||||
ServiceType type = ServiceType.Harbor;
|
||||
type.Should().BeDefined();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provision_Gitea_CreatesInstanceInPendingState()
|
||||
{
|
||||
// Gitea should be provisionable just like any other service type.
|
||||
|
||||
ServiceInstance instance = ServiceInstance.Provision(
|
||||
Guid.NewGuid(), ServiceType.Gitea, "team-gitea", "gitea", Guid.NewGuid());
|
||||
|
||||
instance.ServiceType.Should().Be(ServiceType.Gitea);
|
||||
instance.CurrentState.Should().Be(ServiceState.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provision_Harbor_CreatesInstanceInPendingState()
|
||||
{
|
||||
// Harbor should be provisionable just like any other service type.
|
||||
|
||||
ServiceInstance instance = ServiceInstance.Provision(
|
||||
Guid.NewGuid(), ServiceType.Harbor, "team-harbor", "harbor", Guid.NewGuid());
|
||||
|
||||
instance.ServiceType.Should().Be(ServiceType.Harbor);
|
||||
instance.CurrentState.Should().Be(ServiceState.Pending);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.Apps.AddAppEnvironment;
|
||||
using EntKube.Provisioning.Features.Apps.Reconcile;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class AddAppEnvironmentHandlerTests
|
||||
{
|
||||
private readonly AddAppEnvironmentHandler handler;
|
||||
private readonly InMemoryAppRepository repository;
|
||||
private readonly Mock<IKubernetesApplyClient> applyClientMock;
|
||||
|
||||
public AddAppEnvironmentHandlerTests()
|
||||
{
|
||||
repository = new InMemoryAppRepository();
|
||||
applyClientMock = new Mock<IKubernetesApplyClient>();
|
||||
handler = new AddAppEnvironmentHandler(repository, applyClientMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WithValidRequest_AddsEnvironmentToApp()
|
||||
{
|
||||
// Arrange — An app exists and the admin wants to add it to the dev environment.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
await repository.AddAsync(app);
|
||||
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
AddAppEnvironmentRequest request = new(
|
||||
AppId: app.Id,
|
||||
EnvironmentId: environmentId,
|
||||
ClusterId: clusterId,
|
||||
Namespace: "api-dev");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — The environment is added and its ID returned.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBe(Guid.Empty);
|
||||
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
updated!.Environments.Should().HaveCount(1);
|
||||
updated.Environments[0].EnvironmentId.Should().Be(environmentId);
|
||||
updated.Environments[0].ClusterId.Should().Be(clusterId);
|
||||
updated.Environments[0].Namespace.Should().Be("api-dev");
|
||||
|
||||
// Verify that a namespace manifest was applied to the cluster.
|
||||
|
||||
applyClientMock.Verify(
|
||||
c => c.ApplyManifestAsync(clusterId, It.Is<string>(y => y.Contains("kind: Namespace") && y.Contains("name: api-dev")), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WithNonExistentApp_ReturnsFailure()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
AddAppEnvironmentRequest request = new(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "ns");
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WithDuplicateEnvironment_ReturnsFailure()
|
||||
{
|
||||
// Arrange — The app is already deployed to this environment.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
|
||||
await repository.AddAsync(app);
|
||||
|
||||
AddAppEnvironmentRequest request = new(app.Id, environmentId, Guid.NewGuid(), "api-dev-2");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("already");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.Apps.AddAppSecret;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class AddAppSecretHandlerTests
|
||||
{
|
||||
private readonly AddAppSecretHandler handler;
|
||||
private readonly InMemoryAppRepository repository;
|
||||
|
||||
public AddAppSecretHandlerTests()
|
||||
{
|
||||
repository = new InMemoryAppRepository();
|
||||
handler = new AddAppSecretHandler(repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WithValidRequest_AddsSecretToEnvironment()
|
||||
{
|
||||
// Arrange — An app deployed to dev needs a database connection string.
|
||||
// The actual secret value lives in the vault — we just store the reference.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
|
||||
await repository.AddAsync(app);
|
||||
|
||||
AddAppSecretRequest request = new(
|
||||
AppId: app.Id,
|
||||
EnvironmentId: environmentId,
|
||||
Name: "DATABASE_URL",
|
||||
VaultKey: "database-connection-string");
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
AppEnvironment env = updated!.Environments[0];
|
||||
env.Secrets.Should().HaveCount(1);
|
||||
env.Secrets[0].Name.Should().Be("DATABASE_URL");
|
||||
env.Secrets[0].VaultKey.Should().Be("database-connection-string");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_NonExistentApp_ReturnsFailure()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
AddAppSecretRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "KEY", "vault-key");
|
||||
Result result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_DuplicateSecretName_ReturnsFailure()
|
||||
{
|
||||
// Arrange — A secret with this name already exists.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
AppEnvironment env = app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
|
||||
env.AddSecret("DATABASE_URL", "db-conn");
|
||||
await repository.AddAsync(app);
|
||||
|
||||
AddAppSecretRequest request = new(app.Id, environmentId, "DATABASE_URL", "other-key");
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("already exists");
|
||||
}
|
||||
}
|
||||
387
tests/EntKube.Provisioning.Tests/Features/AppReconcilerTests.cs
Normal file
387
tests/EntKube.Provisioning.Tests/Features/AppReconcilerTests.cs
Normal file
@@ -0,0 +1,387 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.Apps.Reconcile;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class AppReconcilerTests
|
||||
{
|
||||
private readonly InMemoryAppRepository repository;
|
||||
private readonly Mock<IKubernetesApplyClient> applyClient;
|
||||
private readonly Mock<ISecretResolver> secretResolver;
|
||||
private readonly Mock<IClusterGatewayResolver> gatewayResolver;
|
||||
private readonly AppReconcileHandler handler;
|
||||
|
||||
public AppReconcilerTests()
|
||||
{
|
||||
repository = new InMemoryAppRepository();
|
||||
applyClient = new Mock<IKubernetesApplyClient>();
|
||||
secretResolver = new Mock<ISecretResolver>();
|
||||
gatewayResolver = new Mock<IClusterGatewayResolver>();
|
||||
handler = new AppReconcileHandler(repository, applyClient.Object, secretResolver.Object, gatewayResolver.Object);
|
||||
}
|
||||
|
||||
// ─── Deployment-type app reconciliation ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_DeploymentApp_AppliesAllManifests()
|
||||
{
|
||||
// Arrange — A Deployment-type app with a configured environment.
|
||||
// The reconciler should generate and apply deployment.yaml, service.yaml,
|
||||
// and httproute.yaml to the target cluster.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid customerId = Guid.NewGuid();
|
||||
App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment);
|
||||
Guid envId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev");
|
||||
|
||||
env.ConfigureDeployment(new DeploymentSpec(
|
||||
Image: "registry.example.com/api",
|
||||
Tag: "v1.0.0",
|
||||
Replicas: 2,
|
||||
ContainerPort: 8080,
|
||||
ServicePort: 80,
|
||||
HostName: "api.dev.example.com",
|
||||
PathPrefix: "/",
|
||||
EnvironmentVariables: null,
|
||||
Resources: null));
|
||||
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act — Reconcile all pending environments.
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Three manifests should be applied: Deployment, Service, HTTPRoute.
|
||||
|
||||
applyClient.Verify(c => c.ApplyManifestAsync(
|
||||
clusterId,
|
||||
It.Is<string>(y => y.Contains("kind: Deployment")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
applyClient.Verify(c => c.ApplyManifestAsync(
|
||||
clusterId,
|
||||
It.Is<string>(y => y.Contains("kind: Service")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
applyClient.Verify(c => c.ApplyManifestAsync(
|
||||
clusterId,
|
||||
It.Is<string>(y => y.Contains("kind: HTTPRoute")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
// The environment should be marked as Synced after successful reconciliation.
|
||||
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Synced);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_DeploymentAppWithSecrets_ResolvesAndAppliesSecretManifest()
|
||||
{
|
||||
// Arrange — An app with a secret reference. The reconciler should
|
||||
// resolve the vault key, generate a Secret manifest, and apply it.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid customerId = Guid.NewGuid();
|
||||
App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment);
|
||||
Guid envId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev");
|
||||
|
||||
env.ConfigureDeployment(new DeploymentSpec(
|
||||
Image: "api", Tag: "v1", Replicas: 1,
|
||||
ContainerPort: 8080, ServicePort: 80,
|
||||
HostName: null, PathPrefix: null,
|
||||
EnvironmentVariables: null, Resources: null));
|
||||
|
||||
env.AddSecret("DATABASE_URL", "db-connection-string");
|
||||
await repository.AddAsync(app);
|
||||
|
||||
secretResolver.Setup(r => r.ResolveAsync(
|
||||
tenantId, customerId, "api", envId, "db-connection-string",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync("postgresql://db:5432/mydb");
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — A Secret manifest should be applied alongside the Deployment.
|
||||
|
||||
applyClient.Verify(c => c.ApplyManifestAsync(
|
||||
clusterId,
|
||||
It.Is<string>(y => y.Contains("kind: Secret")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
// The Deployment manifest should include secretKeyRef for the secret.
|
||||
|
||||
applyClient.Verify(c => c.ApplyManifestAsync(
|
||||
clusterId,
|
||||
It.Is<string>(y => y.Contains("secretKeyRef:")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
// ─── HelmChart-type app reconciliation ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_HelmChartApp_CallsHelmUpgradeInstall()
|
||||
{
|
||||
// Arrange — A HelmChart-type app. The reconciler should call
|
||||
// HelmUpgradeInstallAsync instead of applying YAML manifests.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid customerId = Guid.NewGuid();
|
||||
App app = App.Create(tenantId, customerId, "Redis", "redis", AppType.HelmChart);
|
||||
Guid envId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
AppEnvironment env = app.AddEnvironment(envId, clusterId, "redis-dev");
|
||||
|
||||
HelmReleaseSpec helmSpec = new(
|
||||
RepoUrl: "https://charts.bitnami.com/bitnami",
|
||||
ChartName: "redis",
|
||||
ChartVersion: "18.6.1",
|
||||
ValuesYaml: "replica:\n replicaCount: 3");
|
||||
|
||||
env.ConfigureHelmRelease(helmSpec);
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Should call helm upgrade install, not apply manifests.
|
||||
|
||||
applyClient.Verify(c => c.HelmUpgradeInstallAsync(
|
||||
clusterId,
|
||||
"redis",
|
||||
"redis-dev",
|
||||
It.Is<HelmReleaseSpec>(s => s.ChartName == "redis" && s.ChartVersion == "18.6.1"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Synced);
|
||||
}
|
||||
|
||||
// ─── Error handling ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_WhenApplyFails_MarksEnvironmentAsError()
|
||||
{
|
||||
// Arrange — The K8s apply call throws an exception.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid envId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev");
|
||||
|
||||
env.ConfigureDeployment(new DeploymentSpec(
|
||||
Image: "api", Tag: "v1", Replicas: 1,
|
||||
ContainerPort: 8080, ServicePort: 80,
|
||||
HostName: null, PathPrefix: null,
|
||||
EnvironmentVariables: null, Resources: null));
|
||||
|
||||
await repository.AddAsync(app);
|
||||
|
||||
applyClient.Setup(c => c.ApplyManifestAsync(
|
||||
clusterId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new HttpRequestException("Cluster unreachable"));
|
||||
|
||||
// Act — Reconcile should not throw — it catches and marks error.
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — The environment should be marked with an error.
|
||||
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Error);
|
||||
updated.Environments[0].SyncStatusMessage.Should().Contain("Cluster unreachable");
|
||||
}
|
||||
|
||||
// ─── Skipping already-synced environments ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_AlreadySynced_SkipsReconciliation()
|
||||
{
|
||||
// Arrange — An environment that's already synced shouldn't be reprocessed.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid envId = Guid.NewGuid();
|
||||
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
|
||||
|
||||
env.ConfigureDeployment(new DeploymentSpec(
|
||||
Image: "api", Tag: "v1", Replicas: 1,
|
||||
ContainerPort: 8080, ServicePort: 80,
|
||||
HostName: null, PathPrefix: null,
|
||||
EnvironmentVariables: null, Resources: null));
|
||||
|
||||
env.MarkSynced();
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — No K8s calls should be made.
|
||||
|
||||
applyClient.Verify(c => c.ApplyManifestAsync(
|
||||
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
|
||||
applyClient.Verify(c => c.HelmUpgradeInstallAsync(
|
||||
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<HelmReleaseSpec>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// ─── Suspended apps ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_SuspendedApp_SkipsReconciliation()
|
||||
{
|
||||
// Arrange — A suspended app should not be reconciled even if
|
||||
// environments are pending.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
|
||||
|
||||
env.ConfigureDeployment(new DeploymentSpec(
|
||||
Image: "api", Tag: "v1", Replicas: 1,
|
||||
ContainerPort: 8080, ServicePort: 80,
|
||||
HostName: null, PathPrefix: null,
|
||||
EnvironmentVariables: null, Resources: null));
|
||||
|
||||
app.Suspend();
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
|
||||
applyClient.Verify(c => c.ApplyManifestAsync(
|
||||
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// ─── Gateway auto-resolution ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_RouteWithoutGateway_InjectsClusterDefaultGateway()
|
||||
{
|
||||
// Arrange — An app with a route that has no GatewayRef specified.
|
||||
// The reconciler should ask the cluster for its default gateway and
|
||||
// inject it into the route manifest automatically.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid customerId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
App app = App.Create(tenantId, customerId, "Web", "web", AppType.Deployment);
|
||||
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), clusterId, "web-dev");
|
||||
|
||||
env.ConfigureDeployment(new DeploymentSpec(
|
||||
Image: "registry.example.com/web",
|
||||
Tag: "v1.0.0",
|
||||
Replicas: 1,
|
||||
ContainerPort: 8080,
|
||||
ServicePort: 80,
|
||||
HostName: null,
|
||||
PathPrefix: null,
|
||||
EnvironmentVariables: null,
|
||||
Resources: null,
|
||||
Services: new List<ServiceSpec>
|
||||
{
|
||||
new("web-http", 80, 8080, "TCP", "ClusterIP")
|
||||
},
|
||||
Routes: new List<RouteSpec>
|
||||
{
|
||||
new("web-route", "HTTP",
|
||||
Hostnames: new List<string> { "web.dev.example.com" },
|
||||
GatewayRef: null,
|
||||
Rules: new List<RouteRule>
|
||||
{
|
||||
new(Matches: new List<RouteMatch> { new("PathPrefix", "/", null) },
|
||||
BackendRefs: new List<RouteBackendRef> { new("web-http", 80, null) },
|
||||
Filters: null)
|
||||
})
|
||||
}));
|
||||
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// The cluster's ingress is Istio with an internal gateway.
|
||||
|
||||
gatewayResolver.Setup(r => r.GetDefaultGatewayAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ResolvedGateway("istio", "internal", "internal-ingress"));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — The route manifest should contain the auto-resolved gateway
|
||||
// parentRef even though the user never specified one.
|
||||
|
||||
applyClient.Verify(c => c.ApplyManifestAsync(
|
||||
clusterId,
|
||||
It.Is<string>(y => y.Contains("name: internal") && y.Contains("namespace: internal-ingress")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_RouteWithExplicitGateway_DoesNotOverride()
|
||||
{
|
||||
// Arrange — An app with a route that already has an explicit GatewayRef.
|
||||
// The reconciler should NOT replace it with the cluster's default.
|
||||
|
||||
Guid tenantId = Guid.NewGuid();
|
||||
Guid customerId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment);
|
||||
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), clusterId, "api-dev");
|
||||
|
||||
env.ConfigureDeployment(new DeploymentSpec(
|
||||
Image: "registry.example.com/api",
|
||||
Tag: "v1.0.0",
|
||||
Replicas: 1,
|
||||
ContainerPort: 8080,
|
||||
ServicePort: 80,
|
||||
HostName: null,
|
||||
PathPrefix: null,
|
||||
EnvironmentVariables: null,
|
||||
Resources: null,
|
||||
Services: new List<ServiceSpec>
|
||||
{
|
||||
new("api-http", 80, 8080, "TCP", "ClusterIP")
|
||||
},
|
||||
Routes: new List<RouteSpec>
|
||||
{
|
||||
new("api-route", "HTTP",
|
||||
Hostnames: new List<string> { "api.dev.example.com" },
|
||||
GatewayRef: new GatewayReference("external", "external-ingress"),
|
||||
Rules: new List<RouteRule>
|
||||
{
|
||||
new(Matches: new List<RouteMatch> { new("PathPrefix", "/", null) },
|
||||
BackendRefs: new List<RouteBackendRef> { new("api-http", 80, null) },
|
||||
Filters: null)
|
||||
})
|
||||
}));
|
||||
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// The cluster has a default internal gateway, but the route specifies external.
|
||||
|
||||
gatewayResolver.Setup(r => r.GetDefaultGatewayAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ResolvedGateway("istio", "internal", "internal-ingress"));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — The route should use the explicit "external" gateway, not "internal".
|
||||
|
||||
applyClient.Verify(c => c.ApplyManifestAsync(
|
||||
clusterId,
|
||||
It.Is<string>(y => y.Contains("name: external") && y.Contains("namespace: external-ingress")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.Apps.ConfigureAppEnvironment;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class ConfigureAppEnvironmentHandlerTests
|
||||
{
|
||||
private readonly ConfigureAppEnvironmentHandler handler;
|
||||
private readonly InMemoryAppRepository repository;
|
||||
|
||||
public ConfigureAppEnvironmentHandlerTests()
|
||||
{
|
||||
repository = new InMemoryAppRepository();
|
||||
handler = new ConfigureAppEnvironmentHandler(repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_DeploymentSpec_ConfiguresEnvironment()
|
||||
{
|
||||
// Arrange — An app with an environment that needs deployment config.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
|
||||
await repository.AddAsync(app);
|
||||
|
||||
ConfigureDeploymentRequest request = new(
|
||||
AppId: app.Id,
|
||||
EnvironmentId: environmentId,
|
||||
Spec: new DeploymentSpec(
|
||||
Image: "registry.example.com/api",
|
||||
Tag: "v1.0.0",
|
||||
Replicas: 2,
|
||||
ContainerPort: 8080,
|
||||
ServicePort: 80,
|
||||
HostName: "api.dev.example.com",
|
||||
PathPrefix: "/",
|
||||
EnvironmentVariables: new Dictionary<string, string> { ["ENV"] = "dev" },
|
||||
Resources: new ResourceSpec("100m", "500m", "128Mi", "512Mi")));
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleDeploymentAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
AppEnvironment env = updated!.Environments[0];
|
||||
env.DeploymentSpec.Should().NotBeNull();
|
||||
env.DeploymentSpec!.Image.Should().Be("registry.example.com/api");
|
||||
env.SyncStatus.Should().Be(AppSyncStatus.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_HelmSpec_ConfiguresEnvironment()
|
||||
{
|
||||
// Arrange — A Helm-type app with an environment that needs chart config.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "Redis", "redis", AppType.HelmChart);
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
app.AddEnvironment(environmentId, Guid.NewGuid(), "redis-dev");
|
||||
await repository.AddAsync(app);
|
||||
|
||||
ConfigureHelmReleaseRequest request = new(
|
||||
AppId: app.Id,
|
||||
EnvironmentId: environmentId,
|
||||
Spec: new HelmReleaseSpec(
|
||||
RepoUrl: "https://charts.bitnami.com/bitnami",
|
||||
ChartName: "redis",
|
||||
ChartVersion: "18.6.1",
|
||||
ValuesYaml: "replica:\n replicaCount: 3"));
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleHelmReleaseAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
AppEnvironment env = updated!.Environments[0];
|
||||
env.HelmReleaseSpec.Should().NotBeNull();
|
||||
env.HelmReleaseSpec!.ChartName.Should().Be("redis");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_NonExistentApp_ReturnsFailure()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
ConfigureDeploymentRequest request = new(Guid.NewGuid(), Guid.NewGuid(),
|
||||
new DeploymentSpec("img", "v1", 1, 80, 80, null, null, null, null));
|
||||
|
||||
Result result = await handler.HandleDeploymentAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_NonExistentEnvironment_ReturnsFailure()
|
||||
{
|
||||
// Arrange — App exists but the environment ID doesn't match.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
await repository.AddAsync(app);
|
||||
|
||||
ConfigureDeploymentRequest request = new(app.Id, Guid.NewGuid(),
|
||||
new DeploymentSpec("img", "v1", 1, 80, 80, null, null, null, null));
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleDeploymentAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.Apps.CreateApp;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class CreateAppHandlerTests
|
||||
{
|
||||
private readonly CreateAppHandler handler;
|
||||
private readonly InMemoryAppRepository repository;
|
||||
|
||||
public CreateAppHandlerTests()
|
||||
{
|
||||
repository = new InMemoryAppRepository();
|
||||
handler = new CreateAppHandler(repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WithValidRequest_CreatesAppAndReturnsId()
|
||||
{
|
||||
// Arrange — A customer admin creates a new Deployment-type app.
|
||||
|
||||
CreateAppRequest request = new(
|
||||
TenantId: Guid.NewGuid(),
|
||||
CustomerId: Guid.NewGuid(),
|
||||
Name: "Frontend Portal",
|
||||
Slug: "frontend-portal",
|
||||
Type: AppType.Deployment);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — The app is created and its ID returned.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBe(Guid.Empty);
|
||||
|
||||
App? stored = await repository.GetByIdAsync(result.Value!);
|
||||
stored.Should().NotBeNull();
|
||||
stored!.Name.Should().Be("Frontend Portal");
|
||||
stored.Type.Should().Be(AppType.Deployment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WithDuplicateSlug_ReturnsFailure()
|
||||
{
|
||||
// Arrange — An app with the same slug already exists for this customer.
|
||||
|
||||
Guid customerId = Guid.NewGuid();
|
||||
|
||||
CreateAppRequest first = new(Guid.NewGuid(), customerId, "App One", "my-app", AppType.Deployment);
|
||||
await handler.HandleAsync(first);
|
||||
|
||||
CreateAppRequest duplicate = new(Guid.NewGuid(), customerId, "App Two", "my-app", AppType.HelmChart);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(duplicate);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WithEmptyName_ReturnsFailure()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
CreateAppRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "", "slug", AppType.Deployment);
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WithHelmChartType_CreatesHelmApp()
|
||||
{
|
||||
// Arrange — A customer wants to deploy a Helm chart-based app.
|
||||
|
||||
CreateAppRequest request = new(
|
||||
TenantId: Guid.NewGuid(),
|
||||
CustomerId: Guid.NewGuid(),
|
||||
Name: "Redis Cache",
|
||||
Slug: "redis-cache",
|
||||
Type: AppType.HelmChart);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
App? stored = await repository.GetByIdAsync(result.Value!);
|
||||
stored.Should().NotBeNull();
|
||||
stored!.Type.Should().Be(AppType.HelmChart);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.DatabaseStatusReconcile;
|
||||
using EntKube.Provisioning.Features.MongoClusters;
|
||||
using EntKube.Provisioning.Features.PostgresClusters;
|
||||
using EntKube.Provisioning.Features.RedisClusters;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the DatabaseStatusReconcileHandler, which periodically checks
|
||||
/// Kubernetes CRD status for CNPG, Redis, and MongoDB clusters and transitions
|
||||
/// the domain models from Provisioning → Running (or Degraded) based on live state.
|
||||
///
|
||||
/// This is the missing piece that caused database clusters to stay stuck in
|
||||
/// "Provisioning" status forever even after Kubernetes reported them as healthy.
|
||||
/// </summary>
|
||||
public class DatabaseStatusReconcileHandlerTests
|
||||
{
|
||||
private readonly InMemoryPostgresClusterRepository pgRepository;
|
||||
private readonly InMemoryRedisClusterRepository redisRepository;
|
||||
private readonly InMemoryMongoClusterRepository mongoRepository;
|
||||
private readonly Mock<ICnpgClusterClient> cnpgClient;
|
||||
private readonly Mock<IRedisClusterClient> redisClient;
|
||||
private readonly Mock<IMongoClusterClient> mongoClient;
|
||||
private readonly Mock<IClusterCredentialsResolver> credentialsResolver;
|
||||
private readonly DatabaseStatusReconcileHandler handler;
|
||||
|
||||
private const string FakeKubeConfig = "apiVersion: v1\nclusters: []";
|
||||
private const string FakeContextName = "test-context";
|
||||
|
||||
public DatabaseStatusReconcileHandlerTests()
|
||||
{
|
||||
pgRepository = new InMemoryPostgresClusterRepository();
|
||||
redisRepository = new InMemoryRedisClusterRepository();
|
||||
mongoRepository = new InMemoryMongoClusterRepository();
|
||||
cnpgClient = new Mock<ICnpgClusterClient>();
|
||||
redisClient = new Mock<IRedisClusterClient>();
|
||||
mongoClient = new Mock<IMongoClusterClient>();
|
||||
credentialsResolver = new Mock<IClusterCredentialsResolver>();
|
||||
|
||||
handler = new DatabaseStatusReconcileHandler(
|
||||
pgRepository,
|
||||
redisRepository,
|
||||
mongoRepository,
|
||||
cnpgClient.Object,
|
||||
redisClient.Object,
|
||||
mongoClient.Object,
|
||||
credentialsResolver.Object);
|
||||
}
|
||||
|
||||
// ─── CNPG PostgreSQL ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_CnpgClusterHealthy_TransitionsToRunning()
|
||||
{
|
||||
// Arrange — A freshly provisioned CNPG cluster still in Provisioning state.
|
||||
// Kubernetes reports it as healthy (phase = "Cluster in healthy state").
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "test-db", "cnpg-system", "16.4", 3, "50Gi");
|
||||
|
||||
await pgRepository.AddAsync(pgCluster);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
cnpgClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "test-db", "cnpg-system", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 3, 3, "test-db-1", null, null));
|
||||
|
||||
// Act — The reconciler checks the status and transitions accordingly.
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — The cluster should now be Running.
|
||||
|
||||
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
|
||||
updated!.Status.Should().Be(PostgresClusterStatus.Running);
|
||||
updated.LastReconcileAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_CnpgClusterNotReady_StaysProvisioning()
|
||||
{
|
||||
// Arrange — A CNPG cluster where K8s reports it's still setting up
|
||||
// (not all instances are ready yet).
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "test-db", "cnpg-system", "16.4", 3, "50Gi");
|
||||
|
||||
await pgRepository.AddAsync(pgCluster);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
cnpgClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "test-db", "cnpg-system", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CnpgClusterStatus("Setting up primary", 0, 3, null, null, null));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Still provisioning; not all instances are ready.
|
||||
|
||||
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
|
||||
updated!.Status.Should().Be(PostgresClusterStatus.Provisioning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_CnpgClusterAlreadyRunning_SkipsButChecksForDegradation()
|
||||
{
|
||||
// Arrange — A cluster that's already Running. The reconciler should check
|
||||
// its health and mark it Degraded if it has become unhealthy.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "healthy-db", "cnpg-system", "16.4", 3, "50Gi");
|
||||
pgCluster.MarkRunning();
|
||||
|
||||
await pgRepository.AddAsync(pgCluster);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
cnpgClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "healthy-db", "cnpg-system", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 2, 3, "healthy-db-1", null, null));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Not all instances ready → mark degraded.
|
||||
|
||||
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
|
||||
updated!.Status.Should().Be(PostgresClusterStatus.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_CnpgUpgrading_CompletesUpgradeWhenHealthy()
|
||||
{
|
||||
// Arrange — A cluster that's mid-upgrade. When K8s reports healthy at the
|
||||
// target version, the reconciler should complete the upgrade.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "upgrade-db", "cnpg-system", "15.6", 3, "50Gi");
|
||||
pgCluster.MarkRunning();
|
||||
pgCluster.RequestUpgrade("16.4");
|
||||
|
||||
await pgRepository.AddAsync(pgCluster);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
cnpgClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "upgrade-db", "cnpg-system", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 3, 3, "upgrade-db-1", null, null));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Upgrade completed, back to Running.
|
||||
|
||||
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
|
||||
updated!.Status.Should().Be(PostgresClusterStatus.Running);
|
||||
updated.PostgresVersion.Should().Be("16.4");
|
||||
}
|
||||
|
||||
// ─── Redis ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_RedisClusterReady_TransitionsToRunning()
|
||||
{
|
||||
// Arrange — A freshly provisioned Redis cluster still in Provisioning state.
|
||||
// Kubernetes reports all replicas are ready.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
RedisCluster redis = RedisCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "test-redis", "redis", "7.2.5", 3, "10Gi", true);
|
||||
|
||||
await redisRepository.AddAsync(redis);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
redisClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "test-redis", "redis", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RedisCrdStatus("Ready", 3, 3, "test-redis-0"));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — The Redis cluster should now be Running.
|
||||
|
||||
RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id);
|
||||
updated!.Status.Should().Be(RedisClusterStatus.Running);
|
||||
updated.LastReconcileAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_RedisClusterNotReady_StaysProvisioning()
|
||||
{
|
||||
// Arrange — Redis operator reports Unknown phase with no ready replicas.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
RedisCluster redis = RedisCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "test-redis", "redis", "7.2.5", 3, "10Gi", true);
|
||||
|
||||
await redisRepository.AddAsync(redis);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
redisClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "test-redis", "redis", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RedisCrdStatus("Unknown", 0, 0, null));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Still provisioning.
|
||||
|
||||
RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id);
|
||||
updated!.Status.Should().Be(RedisClusterStatus.Provisioning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_RedisUpgrading_CompletesUpgradeWhenReady()
|
||||
{
|
||||
// Arrange — Redis cluster mid-upgrade.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
RedisCluster redis = RedisCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "upgrade-redis", "redis", "7.0.15", 3, "10Gi", true);
|
||||
redis.MarkRunning();
|
||||
redis.RequestUpgrade("7.2.5");
|
||||
|
||||
await redisRepository.AddAsync(redis);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
redisClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "upgrade-redis", "redis", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RedisCrdStatus("Ready", 3, 3, "upgrade-redis-0"));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Upgrade completed.
|
||||
|
||||
RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id);
|
||||
updated!.Status.Should().Be(RedisClusterStatus.Running);
|
||||
updated.RedisVersion.Should().Be("7.2.5");
|
||||
}
|
||||
|
||||
// ─── MongoDB ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_MongoClusterRunningPhase_TransitionsToRunning()
|
||||
{
|
||||
// Arrange — A freshly provisioned MongoDB cluster still in Provisioning.
|
||||
// The operator reports phase = "Running" with all members ready.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
MongoCluster mongo = MongoCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "test-mongo", "mongodb-system", "7.0.12", 3, "50Gi");
|
||||
|
||||
await mongoRepository.AddAsync(mongo);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
mongoClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "test-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MongoCrdStatus("Running", 3, 3, "test-mongo-0", null));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — The MongoDB cluster should now be Running.
|
||||
|
||||
MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id);
|
||||
updated!.Status.Should().Be(MongoClusterStatus.Running);
|
||||
updated.LastReconcileAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_MongoClusterFailed_MarksDegraded()
|
||||
{
|
||||
// Arrange — A MongoDB cluster where the operator reports phase = "Failed".
|
||||
// This matches the user's scenario where MongoDB shows "Failed" with 0/3 ready.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
MongoCluster mongo = MongoCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "failed-mongo", "mongodb-system", "7.0.12", 3, "50Gi");
|
||||
|
||||
await mongoRepository.AddAsync(mongo);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
mongoClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "failed-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MongoCrdStatus("Failed", 0, 3, null, null));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Failed phase → Degraded with message.
|
||||
|
||||
MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id);
|
||||
updated!.Status.Should().Be(MongoClusterStatus.Degraded);
|
||||
updated.StatusMessage.Should().Contain("Failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_MongoClusterNotReady_StaysProvisioning()
|
||||
{
|
||||
// Arrange — MongoDB with pending phase and 0 ready members.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
MongoCluster mongo = MongoCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "pending-mongo", "mongodb-system", "7.0.12", 3, "50Gi");
|
||||
|
||||
await mongoRepository.AddAsync(mongo);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
mongoClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "pending-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MongoCrdStatus("Pending", 0, 3, null, null));
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Still provisioning.
|
||||
|
||||
MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id);
|
||||
updated!.Status.Should().Be(MongoClusterStatus.Provisioning);
|
||||
}
|
||||
|
||||
// ─── Credential resolution failures ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_CredentialResolutionFails_SkipsCluster()
|
||||
{
|
||||
// Arrange — Can't reach the Clusters service. The reconciler should skip
|
||||
// this cluster without crashing and move on to others.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "unreachable-db", "cnpg-system", "16.4", 3, "50Gi");
|
||||
|
||||
await pgRepository.AddAsync(pgCluster);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ClusterCredentials?)null);
|
||||
|
||||
// Act — Should not throw.
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Status unchanged.
|
||||
|
||||
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
|
||||
updated!.Status.Should().Be(PostgresClusterStatus.Provisioning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_StatusQueryReturnsNull_DoesNotChangeStatus()
|
||||
{
|
||||
// Arrange — The K8s status query returns null (cluster CR not found).
|
||||
// The reconciler should leave the status unchanged.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), clusterId, "missing-cr-db", "cnpg-system", "16.4", 3, "50Gi");
|
||||
|
||||
await pgRepository.AddAsync(pgCluster);
|
||||
|
||||
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
|
||||
|
||||
cnpgClient.Setup(c => c.GetClusterStatusAsync(
|
||||
FakeKubeConfig, FakeContextName, "missing-cr-db", "cnpg-system", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CnpgClusterStatus?)null);
|
||||
|
||||
// Act
|
||||
|
||||
await handler.ReconcileAsync(CancellationToken.None);
|
||||
|
||||
// Assert — Status unchanged.
|
||||
|
||||
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
|
||||
updated!.Status.Should().Be(PostgresClusterStatus.Provisioning);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.DecommissionService;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class DecommissionServiceHandlerTests
|
||||
{
|
||||
private readonly DecommissionServiceHandler handler;
|
||||
private readonly InMemoryServiceInstanceRepository repository;
|
||||
|
||||
public DecommissionServiceHandlerTests()
|
||||
{
|
||||
repository = new InMemoryServiceInstanceRepository();
|
||||
handler = new DecommissionServiceHandler(repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WithRunningService_SetsDesiredStateToDecommissioned()
|
||||
{
|
||||
// Arrange — A running service exists in the repository.
|
||||
|
||||
ServiceInstance instance = ServiceInstance.Provision(
|
||||
Guid.NewGuid(),
|
||||
ServiceType.MinIO,
|
||||
"tenant-storage",
|
||||
"minio-system",
|
||||
Guid.NewGuid());
|
||||
|
||||
instance.MarkRunning();
|
||||
await repository.AddAsync(instance);
|
||||
|
||||
// Act — Request decommission.
|
||||
|
||||
Result result = await handler.HandleAsync(instance.Id);
|
||||
|
||||
// Assert — The desired state should be Decommissioned.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
ServiceInstance? updated = await repository.GetByIdAsync(instance.Id);
|
||||
updated.Should().NotBeNull();
|
||||
updated!.DesiredState.Should().Be(ServiceState.Decommissioned);
|
||||
}
|
||||
|
||||
[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,51 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.Apps.DeleteApp;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class DeleteAppHandlerTests
|
||||
{
|
||||
private readonly InMemoryAppRepository repository;
|
||||
private readonly DeleteAppHandler handler;
|
||||
|
||||
public DeleteAppHandlerTests()
|
||||
{
|
||||
repository = new InMemoryAppRepository();
|
||||
handler = new DeleteAppHandler(repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_ExistingApp_DeletesSuccessfully()
|
||||
{
|
||||
// Arrange — An existing app in the repository.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(app.Id);
|
||||
|
||||
// Assert — The app should be gone from the repository.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
App? deleted = await repository.GetByIdAsync(app.Id);
|
||||
deleted.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
|
||||
{
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for EF Core provisioning repositories. Uses a real SQLite
|
||||
/// database to verify that change tracking works correctly when adding child
|
||||
/// entities with pre-set Guid keys to tracked aggregates.
|
||||
/// </summary>
|
||||
public class EfIdentityRealmRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection connection;
|
||||
private readonly ProvisioningDbContext db;
|
||||
private readonly EfIdentityRealmRepository repository;
|
||||
|
||||
public EfIdentityRealmRepositoryTests()
|
||||
{
|
||||
connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
DbContextOptions<ProvisioningDbContext> options = new DbContextOptionsBuilder<ProvisioningDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
db = new ProvisioningDbContext(options);
|
||||
db.Database.EnsureCreated();
|
||||
repository = new EfIdentityRealmRepository(db);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_AddingIdentityProvider_DoesNotThrowConcurrencyException()
|
||||
{
|
||||
// Arrange — Create and persist a realm, then load it back.
|
||||
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
IdentityRealm realm = IdentityRealm.Create(environmentId, "test-realm", clusterId);
|
||||
await repository.AddAsync(realm);
|
||||
|
||||
IdentityRealm? loaded = await repository.GetByIdAsync(realm.Id);
|
||||
loaded.Should().NotBeNull();
|
||||
|
||||
// Act — Add a new identity provider to the tracked realm and save.
|
||||
// Before the fix, Update() would mark the new provider as Modified
|
||||
// instead of Added, causing DbUpdateConcurrencyException.
|
||||
|
||||
RealmIdentityProvider idp = RealmIdentityProvider.Create(
|
||||
"google", "Google", IdentityProviderType.Google);
|
||||
|
||||
loaded!.AddIdentityProvider(idp);
|
||||
|
||||
Func<Task> act = async () => await repository.UpdateAsync(loaded);
|
||||
|
||||
// Assert — Should save without throwing.
|
||||
|
||||
await act.Should().NotThrowAsync<DbUpdateConcurrencyException>();
|
||||
|
||||
IdentityRealm? reloaded = await repository.GetByIdAsync(realm.Id);
|
||||
reloaded!.IdentityProviders.Should().HaveCount(1);
|
||||
reloaded.IdentityProviders[0].Alias.Should().Be("google");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_AddingOrganization_DoesNotThrowConcurrencyException()
|
||||
{
|
||||
// Arrange — Create and persist a realm.
|
||||
|
||||
Guid environmentId = Guid.NewGuid();
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
IdentityRealm realm = IdentityRealm.Create(environmentId, "org-realm", clusterId);
|
||||
await repository.AddAsync(realm);
|
||||
|
||||
IdentityRealm? loaded = await repository.GetByIdAsync(realm.Id);
|
||||
loaded.Should().NotBeNull();
|
||||
|
||||
// Act — Add a new organization and save.
|
||||
|
||||
RealmOrganization org = RealmOrganization.Create("Engineering", "Dev teams");
|
||||
loaded!.AddOrganization(org);
|
||||
|
||||
Func<Task> act = async () => await repository.UpdateAsync(loaded);
|
||||
|
||||
// Assert
|
||||
|
||||
await act.Should().NotThrowAsync<DbUpdateConcurrencyException>();
|
||||
|
||||
IdentityRealm? reloaded = await repository.GetByIdAsync(realm.Id);
|
||||
reloaded!.Organizations.Should().HaveCount(1);
|
||||
reloaded.Organizations[0].Name.Should().Be("Engineering");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
db.Dispose();
|
||||
connection.Dispose();
|
||||
}
|
||||
}
|
||||
1107
tests/EntKube.Provisioning.Tests/Features/ManifestGeneratorTests.cs
Normal file
1107
tests/EntKube.Provisioning.Tests/Features/ManifestGeneratorTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
255
tests/EntKube.Provisioning.Tests/Features/MinioAdoptionTests.cs
Normal file
255
tests/EntKube.Provisioning.Tests/Features/MinioAdoptionTests.cs
Normal file
@@ -0,0 +1,255 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.MinioInstances;
|
||||
using EntKube.Provisioning.Features.MinioInstances.AdoptMinioInstance;
|
||||
using EntKube.Provisioning.Features.PostgresClusters;
|
||||
using EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for MinIO adoption and its integration with CNPG cluster provisioning.
|
||||
/// MinIO must be adopted before CNPG clusters can be created — the create flow
|
||||
/// resolves the MinIO details by reference (cluster ID) rather than raw values.
|
||||
/// </summary>
|
||||
public class MinioAdoptionTests
|
||||
{
|
||||
private readonly InMemoryMinioInstanceRepository minioRepository;
|
||||
private readonly InMemoryPostgresClusterRepository pgRepository;
|
||||
private readonly Mock<IMinioClient> minioClient;
|
||||
private readonly Mock<ICnpgClusterClient> cnpgClient;
|
||||
|
||||
public MinioAdoptionTests()
|
||||
{
|
||||
minioRepository = new InMemoryMinioInstanceRepository();
|
||||
pgRepository = new InMemoryPostgresClusterRepository();
|
||||
minioClient = new Mock<IMinioClient>();
|
||||
cnpgClient = new Mock<ICnpgClusterClient>();
|
||||
}
|
||||
|
||||
// ─── Adopt MinIO ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptMinio_WithDiscoveredTenant_CreatesRunningInstance()
|
||||
{
|
||||
// Arrange — The K8s cluster has an existing MinIO tenant running.
|
||||
|
||||
minioClient.Setup(c => c.DiscoverMinioAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DiscoveredMinioInstance(
|
||||
Name: "platform-minio",
|
||||
Namespace: "minio-system",
|
||||
Endpoint: "minio.minio-system.svc:9000",
|
||||
ConsoleEndpoint: "minio-console.minio-system.svc:9001",
|
||||
CredentialsSecret: "minio-root-credentials",
|
||||
TotalCapacity: "1Ti",
|
||||
UsedCapacity: "250Gi",
|
||||
Buckets: new List<string> { "loki-logs", "velero-backups" }));
|
||||
|
||||
AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object);
|
||||
|
||||
AdoptMinioInstanceRequest request = new(
|
||||
ClusterId: Guid.NewGuid(),
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — MinIO adopted and stored with all discovered details.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBe(Guid.Empty);
|
||||
|
||||
IReadOnlyList<MinioInstance> all = await minioRepository.GetAllAsync();
|
||||
all.Should().HaveCount(1);
|
||||
all[0].Name.Should().Be("platform-minio");
|
||||
all[0].Endpoint.Should().Be("minio.minio-system.svc:9000");
|
||||
all[0].Status.Should().Be(MinioInstanceStatus.Running);
|
||||
all[0].Buckets.Should().Contain("loki-logs");
|
||||
all[0].Buckets.Should().Contain("velero-backups");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptMinio_NoTenantFound_ReturnsFailure()
|
||||
{
|
||||
// Arrange — No MinIO tenant on this cluster.
|
||||
|
||||
minioClient.Setup(c => c.DiscoverMinioAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((DiscoveredMinioInstance?)null);
|
||||
|
||||
AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object);
|
||||
|
||||
AdoptMinioInstanceRequest request = new(Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("No MinIO");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptMinio_AlreadyAdopted_ReturnsExistingId()
|
||||
{
|
||||
// Arrange — MinIO was already adopted for this cluster.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
MinioInstance existing = MinioInstance.Adopt(
|
||||
clusterId, "platform-minio", "minio-system",
|
||||
"minio:9000", null, "secret", "500Gi", null, null);
|
||||
await minioRepository.AddAsync(existing);
|
||||
|
||||
minioClient.Setup(c => c.DiscoverMinioAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DiscoveredMinioInstance(
|
||||
"platform-minio", "minio-system", "minio:9000",
|
||||
null, "secret", "500Gi", null, null));
|
||||
|
||||
AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object);
|
||||
|
||||
AdoptMinioInstanceRequest request = new(clusterId, "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Returns the existing ID without creating a duplicate.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().Be(existing.Id);
|
||||
|
||||
IReadOnlyList<MinioInstance> all = await minioRepository.GetAllAsync();
|
||||
all.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
// ─── CNPG Create with MinIO Reference ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePostgresCluster_WithMinioInstanceId_ResolvesMinioConfig()
|
||||
{
|
||||
// Arrange — An adopted MinIO instance exists. When creating a CNPG cluster,
|
||||
// we reference it by the cluster ID instead of providing raw details.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
MinioInstance minio = MinioInstance.Adopt(
|
||||
clusterId, "platform-minio", "minio-system",
|
||||
"minio.minio-system.svc:9000", null,
|
||||
"minio-root-credentials", "1Ti", null,
|
||||
new List<string> { "cnpg-backups" });
|
||||
await minioRepository.AddAsync(minio);
|
||||
|
||||
CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository);
|
||||
|
||||
CreatePostgresClusterRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: clusterId,
|
||||
Name: "prod-db",
|
||||
Namespace: "cnpg-system",
|
||||
PostgresVersion: "16.4",
|
||||
Instances: 3,
|
||||
StorageSize: "50Gi",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
MinioEndpoint: null,
|
||||
MinioBucket: "cnpg-backups",
|
||||
MinioCredentialsSecret: null);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — MinIO details resolved from the adopted instance.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
IReadOnlyList<PostgresCluster> clusters = await pgRepository.GetAllAsync();
|
||||
clusters.Should().HaveCount(1);
|
||||
clusters[0].BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000");
|
||||
clusters[0].BackupConfig.CredentialsSecret.Should().Be("minio-root-credentials");
|
||||
clusters[0].BackupConfig.Bucket.Should().Be("cnpg-backups");
|
||||
|
||||
cnpgClient.Verify(c => c.CreateClusterAsync(
|
||||
"config", "ctx",
|
||||
It.Is<CnpgClusterSpec>(s =>
|
||||
s.MinioEndpoint == "minio.minio-system.svc:9000" &&
|
||||
s.MinioCredentialsSecret == "minio-root-credentials"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePostgresCluster_NoMinioAdopted_CreatesWithoutBackup()
|
||||
{
|
||||
// Arrange — No MinIO adopted for this cluster, and no explicit endpoint provided.
|
||||
// The cluster should still be created, just without backup configuration.
|
||||
|
||||
CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository);
|
||||
|
||||
CreatePostgresClusterRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: Guid.NewGuid(),
|
||||
Name: "prod-db",
|
||||
Namespace: "cnpg-system",
|
||||
PostgresVersion: "16.4",
|
||||
Instances: 3,
|
||||
StorageSize: "50Gi",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
MinioEndpoint: null,
|
||||
MinioBucket: "backups",
|
||||
MinioCredentialsSecret: null);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Cluster is created successfully without backup.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePostgresCluster_WithExplicitMinioDetails_StillWorks()
|
||||
{
|
||||
// Arrange — Explicit MinIO details provided (bypass adoption lookup).
|
||||
|
||||
CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository);
|
||||
|
||||
CreatePostgresClusterRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: Guid.NewGuid(),
|
||||
Name: "prod-db",
|
||||
Namespace: "cnpg-system",
|
||||
PostgresVersion: "16.4",
|
||||
Instances: 3,
|
||||
StorageSize: "50Gi",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
MinioEndpoint: "custom-minio:9000",
|
||||
MinioBucket: "custom-bucket",
|
||||
MinioCredentialsSecret: "custom-secret");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Explicit values used directly.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
IReadOnlyList<PostgresCluster> clusters = await pgRepository.GetAllAsync();
|
||||
clusters[0].BackupConfig!.MinioEndpoint.Should().Be("custom-minio:9000");
|
||||
clusters[0].BackupConfig.Bucket.Should().Be("custom-bucket");
|
||||
clusters[0].BackupConfig.CredentialsSecret.Should().Be("custom-secret");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.MinioTenants;
|
||||
using EntKube.Provisioning.Features.MinioTenants.AdoptMinioTenants;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for MinIO Tenant adoption — discovering existing MinIO Tenant CRDs on a
|
||||
/// Kubernetes cluster and importing them into the platform's domain so they can be
|
||||
/// viewed, managed, and monitored. This follows the same pattern as CNPG cluster
|
||||
/// adoption: the Provisioning service scans the cluster, finds what's already running,
|
||||
/// and creates domain aggregates for each tenant.
|
||||
/// </summary>
|
||||
public class MinioTenantAdoptionTests
|
||||
{
|
||||
private readonly InMemoryMinioTenantRepository tenantRepository;
|
||||
private readonly Mock<IMinioTenantClient> tenantClient;
|
||||
|
||||
public MinioTenantAdoptionTests()
|
||||
{
|
||||
tenantRepository = new InMemoryMinioTenantRepository();
|
||||
tenantClient = new Mock<IMinioTenantClient>();
|
||||
}
|
||||
|
||||
// ─── Adopt Tenants ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptTenants_WithExistingTenants_ImportsThemAsRunning()
|
||||
{
|
||||
// Arrange — The Kubernetes cluster has two MinIO tenants already running:
|
||||
// one for platform storage and one for application data. Neither of them
|
||||
// exists in our repository yet, so adoption should discover and import both.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
tenantClient.Setup(c => c.DiscoverTenantsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredMinioTenant>
|
||||
{
|
||||
new(
|
||||
Name: "platform-storage",
|
||||
Namespace: "minio-platform",
|
||||
Pools: new List<DiscoveredMinioPool>
|
||||
{
|
||||
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
|
||||
},
|
||||
CurrentState: "Initialized",
|
||||
Buckets: new List<string> { "cnpg-backups", "loki-logs" }),
|
||||
new(
|
||||
Name: "app-storage",
|
||||
Namespace: "minio-apps",
|
||||
Pools: new List<DiscoveredMinioPool>
|
||||
{
|
||||
new(Servers: 2, VolumesPerServer: 4, StorageSize: "50Gi", StorageClass: "standard")
|
||||
},
|
||||
CurrentState: "Initialized",
|
||||
Buckets: new List<string> { "user-uploads" })
|
||||
});
|
||||
|
||||
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
AdoptMinioTenantsRequest request = new(
|
||||
ClusterId: clusterId,
|
||||
KubeConfig: "kubeconfig-content",
|
||||
ContextName: "prod-context");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Both tenants adopted with correct details.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().HaveCount(2);
|
||||
|
||||
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
|
||||
all.Should().HaveCount(2);
|
||||
|
||||
MinioTenant platformTenant = all.First(t => t.Name == "platform-storage");
|
||||
platformTenant.ClusterId.Should().Be(clusterId);
|
||||
platformTenant.Namespace.Should().Be("minio-platform");
|
||||
platformTenant.Status.Should().Be(MinioTenantStatus.Running);
|
||||
platformTenant.Pools.Should().HaveCount(1);
|
||||
platformTenant.Pools[0].Servers.Should().Be(4);
|
||||
platformTenant.Pools[0].StorageSize.Should().Be("100Gi");
|
||||
|
||||
MinioTenant appTenant = all.First(t => t.Name == "app-storage");
|
||||
appTenant.Status.Should().Be(MinioTenantStatus.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptTenants_NoTenantsOnCluster_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange — The MinIO operator is installed but no tenants are deployed yet.
|
||||
|
||||
tenantClient.Setup(c => c.DiscoverTenantsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredMinioTenant>());
|
||||
|
||||
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
AdoptMinioTenantsRequest request = new(Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Success with empty list, not a failure.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptTenants_AlreadyAdopted_DoesNotDuplicate()
|
||||
{
|
||||
// Arrange — One tenant was already adopted. Running adoption again should
|
||||
// skip it and only import new tenants, not create duplicates.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
MinioTenant existing = MinioTenant.Adopt(
|
||||
clusterId: clusterId,
|
||||
name: "platform-storage",
|
||||
ns: "minio-platform",
|
||||
pools: new List<MinioTenantPool>
|
||||
{
|
||||
new(4, 4, "100Gi", "ceph-block")
|
||||
});
|
||||
|
||||
await tenantRepository.AddAsync(existing);
|
||||
|
||||
tenantClient.Setup(c => c.DiscoverTenantsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredMinioTenant>
|
||||
{
|
||||
new(
|
||||
Name: "platform-storage",
|
||||
Namespace: "minio-platform",
|
||||
Pools: new List<DiscoveredMinioPool>
|
||||
{
|
||||
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
|
||||
},
|
||||
CurrentState: "Initialized",
|
||||
Buckets: new List<string> { "cnpg-backups" }),
|
||||
new(
|
||||
Name: "new-tenant",
|
||||
Namespace: "minio-new",
|
||||
Pools: new List<DiscoveredMinioPool>
|
||||
{
|
||||
new(Servers: 2, VolumesPerServer: 2, StorageSize: "50Gi", StorageClass: "standard")
|
||||
},
|
||||
CurrentState: "Initialized",
|
||||
Buckets: new List<string>())
|
||||
});
|
||||
|
||||
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
AdoptMinioTenantsRequest request = new(clusterId, "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Only one new tenant adopted, the existing one was skipped.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().HaveCount(2);
|
||||
|
||||
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
|
||||
all.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptTenants_DegradedTenant_MarkedDegraded()
|
||||
{
|
||||
// Arrange — A tenant with state "Decommissioning" or some non-healthy
|
||||
// state should be imported but marked as Degraded, not Running.
|
||||
|
||||
tenantClient.Setup(c => c.DiscoverTenantsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredMinioTenant>
|
||||
{
|
||||
new(
|
||||
Name: "sick-tenant",
|
||||
Namespace: "minio-sick",
|
||||
Pools: new List<DiscoveredMinioPool>
|
||||
{
|
||||
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard")
|
||||
},
|
||||
CurrentState: "Decommissioning",
|
||||
Buckets: new List<string>())
|
||||
});
|
||||
|
||||
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
AdoptMinioTenantsRequest request = new(Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
|
||||
all[0].Status.Should().Be(MinioTenantStatus.Degraded);
|
||||
}
|
||||
|
||||
// ─── Domain: MinioTenant.Adopt ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Adopt_WithValidConfig_CreatesRunningTenant()
|
||||
{
|
||||
// Arrange — We discover a healthy tenant with known pools.
|
||||
|
||||
List<MinioTenantPool> pools = new()
|
||||
{
|
||||
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
MinioTenant tenant = MinioTenant.Adopt(
|
||||
clusterId: Guid.NewGuid(),
|
||||
name: "discovered-tenant",
|
||||
ns: "minio-discovered",
|
||||
pools: pools);
|
||||
|
||||
// Assert — Adopted tenants start as Running since they're already serving traffic.
|
||||
|
||||
tenant.Id.Should().NotBe(Guid.Empty);
|
||||
tenant.Name.Should().Be("discovered-tenant");
|
||||
tenant.Namespace.Should().Be("minio-discovered");
|
||||
tenant.Status.Should().Be(MinioTenantStatus.Running);
|
||||
tenant.Pools.Should().HaveCount(1);
|
||||
tenant.Pools[0].Servers.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adopt_WithEmptyName_Throws()
|
||||
{
|
||||
// A tenant with no name is not valid — discovery must provide a name.
|
||||
|
||||
Action act = () => MinioTenant.Adopt(
|
||||
Guid.NewGuid(), "", "ns",
|
||||
new List<MinioTenantPool> { new(1, 1, "10Gi", "standard") });
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using EntKube.Provisioning.Features.MinioTenants;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for parsing MinIO tenant credentials from the various secret formats
|
||||
/// used by different MinIO Operator versions. The operator has changed its
|
||||
/// credential storage format over time:
|
||||
///
|
||||
/// - Legacy (v4): separate "accesskey" and "secretkey" keys in the K8s secret
|
||||
/// - v5+: a single "config.env" key containing export statements
|
||||
/// - Console: "CONSOLE_ACCESS_KEY" and "CONSOLE_SECRET_KEY" keys
|
||||
///
|
||||
/// The credential parser must handle all formats so mc commands can authenticate
|
||||
/// against tenants provisioned by any operator version.
|
||||
/// </summary>
|
||||
public class MinioTenantCredentialParsingTests
|
||||
{
|
||||
// ─── config.env format (MinIO Operator v5+) ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseConfigEnv_WithQuotedValues_ExtractsCredentials()
|
||||
{
|
||||
// The most common format — double-quoted values with export prefix.
|
||||
|
||||
string configEnv = """
|
||||
export MINIO_ROOT_USER="minio-admin"
|
||||
export MINIO_ROOT_PASSWORD="super-secret-password"
|
||||
""";
|
||||
|
||||
// Act
|
||||
|
||||
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
|
||||
|
||||
// Assert
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.accessKey.Should().Be("minio-admin");
|
||||
result!.Value.secretKey.Should().Be("super-secret-password");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConfigEnv_WithSingleQuotedValues_ExtractsCredentials()
|
||||
{
|
||||
// Some installations use single quotes.
|
||||
|
||||
string configEnv = """
|
||||
export MINIO_ROOT_USER='admin'
|
||||
export MINIO_ROOT_PASSWORD='p@ssw0rd'
|
||||
""";
|
||||
|
||||
// Act
|
||||
|
||||
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
|
||||
|
||||
// Assert
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.accessKey.Should().Be("admin");
|
||||
result!.Value.secretKey.Should().Be("p@ssw0rd");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConfigEnv_WithUnquotedValues_ExtractsCredentials()
|
||||
{
|
||||
// Unquoted values — less common but valid.
|
||||
|
||||
string configEnv = """
|
||||
export MINIO_ROOT_USER=minio
|
||||
export MINIO_ROOT_PASSWORD=minio123
|
||||
""";
|
||||
|
||||
// Act
|
||||
|
||||
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
|
||||
|
||||
// Assert
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.accessKey.Should().Be("minio");
|
||||
result!.Value.secretKey.Should().Be("minio123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConfigEnv_WithAdditionalEnvVars_IgnoresExtras()
|
||||
{
|
||||
// The config.env may contain other env vars beyond credentials.
|
||||
// We should extract only the root user/password.
|
||||
|
||||
string configEnv = """
|
||||
export MINIO_ROOT_USER="minio"
|
||||
export MINIO_ROOT_PASSWORD="secret"
|
||||
export MINIO_BROWSER="on"
|
||||
export MINIO_STORAGE_CLASS_STANDARD="EC:2"
|
||||
""";
|
||||
|
||||
// Act
|
||||
|
||||
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
|
||||
|
||||
// Assert
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.accessKey.Should().Be("minio");
|
||||
result!.Value.secretKey.Should().Be("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConfigEnv_MissingUser_ReturnsNull()
|
||||
{
|
||||
// If the user key is missing, we can't authenticate.
|
||||
|
||||
string configEnv = """
|
||||
export MINIO_ROOT_PASSWORD="secret"
|
||||
""";
|
||||
|
||||
// Act
|
||||
|
||||
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
|
||||
|
||||
// Assert
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConfigEnv_MissingPassword_ReturnsNull()
|
||||
{
|
||||
// If the password key is missing, we can't authenticate.
|
||||
|
||||
string configEnv = """
|
||||
export MINIO_ROOT_USER="admin"
|
||||
""";
|
||||
|
||||
// Act
|
||||
|
||||
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
|
||||
|
||||
// Assert
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConfigEnv_EmptyString_ReturnsNull()
|
||||
{
|
||||
// An empty config.env should not crash — just return null.
|
||||
|
||||
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials("");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConfigEnv_WithWhitespaceAndEmptyLines_ExtractsCredentials()
|
||||
{
|
||||
// Real config files often have trailing newlines and inconsistent whitespace.
|
||||
|
||||
string configEnv = "\n export MINIO_ROOT_USER=\"root-user\" \n\n export MINIO_ROOT_PASSWORD=\"root-pass\" \n\n";
|
||||
|
||||
// Act
|
||||
|
||||
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
|
||||
|
||||
// Assert
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.accessKey.Should().Be("root-user");
|
||||
result!.Value.secretKey.Should().Be("root-pass");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConfigEnv_SingleLine_ExtractsCredentials()
|
||||
{
|
||||
// Some environments produce config.env as a single line with all exports
|
||||
// separated by spaces. This happens with MinIO Operator v6 in some configurations.
|
||||
|
||||
string configEnv = """export MINIO_ROOT_USER="minio-admin" export MINIO_ROOT_PASSWORD="sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7" export MINIO_BROWSER="on" export MINIO_PROMETHEUS_AUTH_TYPE="public" """;
|
||||
|
||||
// Act
|
||||
|
||||
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
|
||||
|
||||
// Assert
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.accessKey.Should().Be("minio-admin");
|
||||
result!.Value.secretKey.Should().Be("sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConfigEnv_RealWorldV6Format_ExtractsCredentials()
|
||||
{
|
||||
// Exact format from a real MinIO Operator v6 installation.
|
||||
|
||||
string configEnv = "export MINIO_ROOT_USER=\"minio-admin\"\nexport MINIO_ROOT_PASSWORD=\"sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7\"\nexport MINIO_BROWSER=\"on\"\nexport MINIO_PROMETHEUS_AUTH_TYPE=\"public\"\n";
|
||||
|
||||
// Act
|
||||
|
||||
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
|
||||
|
||||
// Assert
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.accessKey.Should().Be("minio-admin");
|
||||
result!.Value.secretKey.Should().Be("sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.MinioTenants;
|
||||
using EntKube.Provisioning.Features.MinioTenants.CreateMinioTenant;
|
||||
using EntKube.Provisioning.Features.MinioTenants.ConfigureMinioTenant;
|
||||
using EntKube.Provisioning.Features.MinioTenants.DeleteMinioTenant;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for MinIO Tenant provisioning — the full lifecycle of creating, configuring,
|
||||
/// and deleting MinIO tenants on a Kubernetes cluster. Unlike MinIO adoption (which
|
||||
/// discovers existing tenants), this provisions new tenants from scratch via the
|
||||
/// MinIO Operator's Tenant CRD.
|
||||
/// </summary>
|
||||
public class MinioTenantProvisioningTests
|
||||
{
|
||||
private readonly InMemoryMinioTenantRepository tenantRepository;
|
||||
private readonly Mock<IMinioTenantClient> tenantClient;
|
||||
|
||||
public MinioTenantProvisioningTests()
|
||||
{
|
||||
tenantRepository = new InMemoryMinioTenantRepository();
|
||||
tenantClient = new Mock<IMinioTenantClient>();
|
||||
}
|
||||
|
||||
// ─── Create Tenant ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTenant_WithValidConfig_ProvisionsTenantOnCluster()
|
||||
{
|
||||
// Arrange — A platform admin wants a new MinIO tenant with 4 servers,
|
||||
// each with 4 disks of 100Gi, using the ceph-block storage class.
|
||||
|
||||
tenantClient.Setup(c => c.ApplyTenantAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
CreateMinioTenantRequest request = new(
|
||||
ClusterId: Guid.NewGuid(),
|
||||
KubeConfig: "kubeconfig-content",
|
||||
ContextName: "prod-context",
|
||||
Name: "platform-storage",
|
||||
Namespace: "minio-tenant-1",
|
||||
Pools: new List<PoolSpec>
|
||||
{
|
||||
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
|
||||
});
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Tenant created in domain and applied to K8s cluster.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBe(Guid.Empty);
|
||||
|
||||
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
|
||||
all.Should().HaveCount(1);
|
||||
all[0].Name.Should().Be("platform-storage");
|
||||
all[0].Namespace.Should().Be("minio-tenant-1");
|
||||
all[0].Status.Should().Be(MinioTenantStatus.Provisioning);
|
||||
all[0].Pools.Should().HaveCount(1);
|
||||
all[0].Pools[0].Servers.Should().Be(4);
|
||||
all[0].Pools[0].VolumesPerServer.Should().Be(4);
|
||||
|
||||
// Verify the K8s client was called to apply the Tenant CRD.
|
||||
|
||||
tenantClient.Verify(c => c.ApplyTenantAsync(
|
||||
"kubeconfig-content", "prod-context",
|
||||
It.Is<MinioTenant>(t => t.Name == "platform-storage"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTenant_WithMultiplePools_AllPoolsApplied()
|
||||
{
|
||||
// Arrange — Heterogeneous pools: fast NVMe for hot data, HDD for warm.
|
||||
|
||||
tenantClient.Setup(c => c.ApplyTenantAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
CreateMinioTenantRequest request = new(
|
||||
ClusterId: Guid.NewGuid(),
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
Name: "multi-tier",
|
||||
Namespace: "minio-multi",
|
||||
Pools: new List<PoolSpec>
|
||||
{
|
||||
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "nvme-fast"),
|
||||
new(Servers: 2, VolumesPerServer: 8, StorageSize: "500Gi", StorageClass: "hdd-bulk")
|
||||
});
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
|
||||
all[0].Pools.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTenant_WithInvalidConfig_ReturnsFailure()
|
||||
{
|
||||
// Arrange — Zero servers is not valid.
|
||||
|
||||
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
CreateMinioTenantRequest request = new(
|
||||
ClusterId: Guid.NewGuid(),
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
Name: "bad-tenant",
|
||||
Namespace: "minio-bad",
|
||||
Pools: new List<PoolSpec>
|
||||
{
|
||||
new(Servers: 0, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard")
|
||||
});
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Domain validation catches the bad config.
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("server");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTenant_KubernetesClientFails_ReturnsFailure()
|
||||
{
|
||||
// Arrange — Network issue or RBAC problem prevents applying the CRD.
|
||||
|
||||
tenantClient.Setup(c => c.ApplyTenantAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Forbidden: RBAC denied"));
|
||||
|
||||
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
CreateMinioTenantRequest request = new(
|
||||
ClusterId: Guid.NewGuid(),
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
Name: "tenant",
|
||||
Namespace: "minio-ns",
|
||||
Pools: new List<PoolSpec>
|
||||
{
|
||||
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard")
|
||||
});
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Failure reported without tenant being persisted.
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("RBAC");
|
||||
|
||||
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
|
||||
all.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ─── Configure Tenant ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureTenant_UpdatesPools_AndReapplies()
|
||||
{
|
||||
// Arrange — Existing tenant needs scale-out: from 4 to 8 servers.
|
||||
|
||||
MinioTenant existing = MinioTenant.Create(
|
||||
Guid.NewGuid(), "platform-storage", "minio-tenant-1",
|
||||
new List<MinioTenantPool> { new(4, 4, "100Gi", "ceph-block") });
|
||||
existing.MarkRunning();
|
||||
await tenantRepository.AddAsync(existing);
|
||||
|
||||
tenantClient.Setup(c => c.ApplyTenantAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
ConfigureMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
ConfigureMinioTenantRequest request = new(
|
||||
TenantId: existing.Id,
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
Pools: new List<PoolSpec>
|
||||
{
|
||||
new(Servers: 8, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
|
||||
});
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Pools updated, status back to Provisioning, K8s re-applied.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
MinioTenant? updated = await tenantRepository.GetByIdAsync(existing.Id);
|
||||
updated.Should().NotBeNull();
|
||||
updated!.Pools[0].Servers.Should().Be(8);
|
||||
updated.Status.Should().Be(MinioTenantStatus.Provisioning);
|
||||
|
||||
tenantClient.Verify(c => c.ApplyTenantAsync(
|
||||
"config", "ctx",
|
||||
It.Is<MinioTenant>(t => t.Pools[0].Servers == 8),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureTenant_NotFound_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
ConfigureMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
ConfigureMinioTenantRequest request = new(
|
||||
TenantId: Guid.NewGuid(),
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
Pools: new List<PoolSpec> { new(4, 4, "100Gi", "standard") });
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
// ─── Delete Tenant ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTenant_RemovesFromClusterAndMarksDecommissioned()
|
||||
{
|
||||
// Arrange — Platform admin tears down a tenant.
|
||||
|
||||
MinioTenant existing = MinioTenant.Create(
|
||||
Guid.NewGuid(), "old-tenant", "minio-old",
|
||||
new List<MinioTenantPool> { new(4, 4, "100Gi", "standard") });
|
||||
existing.MarkRunning();
|
||||
await tenantRepository.AddAsync(existing);
|
||||
|
||||
tenantClient.Setup(c => c.DeleteTenantAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
DeleteMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
DeleteMinioTenantRequest request = new(
|
||||
TenantId: existing.Id,
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Marked decommissioned and deleted from K8s.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
MinioTenant? tenant = await tenantRepository.GetByIdAsync(existing.Id);
|
||||
tenant.Should().NotBeNull();
|
||||
tenant!.Status.Should().Be(MinioTenantStatus.Decommissioned);
|
||||
|
||||
tenantClient.Verify(c => c.DeleteTenantAsync(
|
||||
"config", "ctx", "old-tenant", "minio-old",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTenant_NotFound_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
DeleteMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
|
||||
|
||||
DeleteMinioTenantRequest request = new(Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.MongoClusters;
|
||||
using EntKube.Provisioning.Features.MongoClusters.AdoptMongoCluster;
|
||||
using EntKube.Provisioning.Features.MongoClusters.CreateMongoCluster;
|
||||
using EntKube.Provisioning.Features.MongoClusters.AddDatabase;
|
||||
using EntKube.Provisioning.Features.MongoClusters.UpgradeMongoCluster;
|
||||
using EntKube.Provisioning.Features.MongoClusters.ConfigureMongoCluster;
|
||||
using EntKube.Provisioning.Features.MongoClusters.RestoreBackup;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for all MongoDB cluster management features. Each test verifies that
|
||||
/// the handler correctly orchestrates domain logic + K8s client interactions.
|
||||
/// </summary>
|
||||
public class MongoClusterFeatureTests
|
||||
{
|
||||
private readonly InMemoryMongoClusterRepository repository;
|
||||
private readonly InMemoryMinioInstanceRepository minioRepository;
|
||||
private readonly Mock<IMongoClusterClient> mongoClient;
|
||||
|
||||
public MongoClusterFeatureTests()
|
||||
{
|
||||
repository = new InMemoryMongoClusterRepository();
|
||||
minioRepository = new InMemoryMinioInstanceRepository();
|
||||
mongoClient = new Mock<IMongoClusterClient>();
|
||||
}
|
||||
|
||||
// ─── Create Cluster ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient()
|
||||
{
|
||||
// Arrange — A platform admin wants a new 3-member MongoDB replica set
|
||||
// with mongodump backups to MinIO.
|
||||
|
||||
CreateMongoClusterHandler handler = new(repository, mongoClient.Object, minioRepository);
|
||||
|
||||
CreateMongoClusterRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: Guid.NewGuid(),
|
||||
Name: "platform-mongo",
|
||||
Namespace: "mongodb-system",
|
||||
MongoVersion: "7.0.12",
|
||||
Members: 3,
|
||||
StorageSize: "50Gi",
|
||||
KubeConfig: "apiVersion: v1\nkind: Config",
|
||||
ContextName: "prod-ctx",
|
||||
MinioEndpoint: "minio.minio-system.svc:9000",
|
||||
MinioBucket: "mongo-backups",
|
||||
MinioCredentialsSecret: "minio-mongo-creds");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Cluster created in repository and K8s client called.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBe(Guid.Empty);
|
||||
|
||||
IReadOnlyList<MongoCluster> clusters = await repository.GetAllAsync();
|
||||
clusters.Should().HaveCount(1);
|
||||
clusters[0].Name.Should().Be("platform-mongo");
|
||||
clusters[0].Status.Should().Be(MongoClusterStatus.Provisioning);
|
||||
|
||||
mongoClient.Verify(c => c.CreateClusterAsync(
|
||||
"apiVersion: v1\nkind: Config",
|
||||
"prod-ctx",
|
||||
It.Is<MongoClusterSpec>(s =>
|
||||
s.Name == "platform-mongo" &&
|
||||
s.MongoVersion == "7.0.12" &&
|
||||
s.Members == 3 &&
|
||||
s.MinioEndpoint == "minio.minio-system.svc:9000"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCluster_WithMissingName_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
CreateMongoClusterHandler handler = new(repository, mongoClient.Object, minioRepository);
|
||||
|
||||
CreateMongoClusterRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: Guid.NewGuid(),
|
||||
Name: "",
|
||||
Namespace: "ns",
|
||||
MongoVersion: "7.0.12",
|
||||
Members: 3,
|
||||
StorageSize: "50Gi",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
MinioEndpoint: "minio:9000",
|
||||
MinioBucket: "bucket",
|
||||
MinioCredentialsSecret: "secret");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("name");
|
||||
}
|
||||
|
||||
// ─── Adopt Cluster ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptCluster_WithDiscoveredClusters_ImportsAll()
|
||||
{
|
||||
// Arrange — The K8s cluster has 2 existing MongoDBCommunity clusters.
|
||||
|
||||
mongoClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredMongoCluster>
|
||||
{
|
||||
new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
|
||||
new List<string> { "app_db", "analytics_db" },
|
||||
"minio.minio-system.svc:9000", "mongo-backups", "minio-creds"),
|
||||
new("staging-mongo", "mongodb-staging", "6.0.16", 1, "20Gi",
|
||||
new List<string> { "staging_db" },
|
||||
"minio.minio-system.svc:9000", "mongo-backups-staging", "minio-creds")
|
||||
});
|
||||
|
||||
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
AdoptMongoClustersRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: Guid.NewGuid(),
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Both clusters adopted and stored in Running state.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().HaveCount(2);
|
||||
|
||||
IReadOnlyList<MongoCluster> all = await repository.GetAllAsync();
|
||||
all.Should().HaveCount(2);
|
||||
all[0].Status.Should().Be(MongoClusterStatus.Running);
|
||||
all[0].Databases.Should().Contain("app_db");
|
||||
all[1].Databases.Should().Contain("staging_db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptCluster_WithNoClusters_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange — No MongoDBCommunity clusters found on K8s.
|
||||
|
||||
mongoClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredMongoCluster>());
|
||||
|
||||
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ─── Add Database ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AddDatabase_ToRunningCluster_CreatesDatabaseOnK8s()
|
||||
{
|
||||
// Arrange — A running MongoDB cluster exists.
|
||||
|
||||
MongoCluster mongoCluster = MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
mongoCluster.MarkRunning();
|
||||
await repository.AddAsync(mongoCluster);
|
||||
|
||||
AddMongoDatabaseHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
AddMongoDatabaseRequest request = new(
|
||||
MongoClusterId: mongoCluster.Id,
|
||||
DatabaseName: "new_app_db",
|
||||
OwnerRole: "app_user",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id);
|
||||
updated!.Databases.Should().Contain("new_app_db");
|
||||
|
||||
mongoClient.Verify(c => c.CreateDatabaseAsync(
|
||||
"config", "ctx", "prod-mongo", "mongodb-system", "new_app_db", "app_user",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddDatabase_ToNonExistentCluster_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
AddMongoDatabaseHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
AddMongoDatabaseRequest request = new(Guid.NewGuid(), "db", "user", "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
// ─── Upgrade Cluster ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpgradeCluster_WithValidVersion_InitiatesUpgradeOnK8s()
|
||||
{
|
||||
// Arrange — A running cluster at version 6.0.16.
|
||||
|
||||
MongoCluster mongoCluster = MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "6.0.16", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
mongoCluster.MarkRunning();
|
||||
await repository.AddAsync(mongoCluster);
|
||||
|
||||
mongoClient.Setup(c => c.GetAvailableVersionsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "6.0.16", "6.0.17", "7.0.12", "7.0.14" });
|
||||
|
||||
UpgradeMongoClusterHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
UpgradeMongoClusterRequest request = new(
|
||||
MongoClusterId: mongoCluster.Id,
|
||||
TargetVersion: "7.0.12",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Domain transitions to Upgrading, K8s client called.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id);
|
||||
updated!.Status.Should().Be(MongoClusterStatus.Upgrading);
|
||||
updated.TargetVersion.Should().Be("7.0.12");
|
||||
|
||||
mongoClient.Verify(c => c.UpgradeClusterAsync(
|
||||
"config", "ctx", "prod-mongo", "mongodb-system", "7.0.12",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpgradeCluster_ToUnavailableVersion_ReturnsFailure()
|
||||
{
|
||||
// Arrange — Version 99.0 doesn't exist.
|
||||
|
||||
MongoCluster mongoCluster = MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
mongoCluster.MarkRunning();
|
||||
await repository.AddAsync(mongoCluster);
|
||||
|
||||
mongoClient.Setup(c => c.GetAvailableVersionsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "7.0.12", "7.0.14" });
|
||||
|
||||
UpgradeMongoClusterHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
UpgradeMongoClusterRequest request = new(mongoCluster.Id, "99.0", "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not available");
|
||||
}
|
||||
|
||||
// ─── Configure Cluster ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureCluster_ScaleMembers_UpdatesK8sAndDomain()
|
||||
{
|
||||
// Arrange — Scale a running cluster from 3 to 5 members.
|
||||
|
||||
MongoCluster mongoCluster = MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
mongoCluster.MarkRunning();
|
||||
await repository.AddAsync(mongoCluster);
|
||||
|
||||
ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
ConfigureMongoClusterRequest request = new(
|
||||
MongoClusterId: mongoCluster.Id,
|
||||
Members: 5,
|
||||
StorageSize: null,
|
||||
BackupSchedule: null,
|
||||
BackupRetentionDays: null,
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id);
|
||||
updated!.Members.Should().Be(5);
|
||||
|
||||
mongoClient.Verify(c => c.UpdateClusterAsync(
|
||||
"config", "ctx",
|
||||
It.Is<MongoClusterSpec>(s => s.Members == 5),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureCluster_UpdateBackupSchedule_UpdatesBackupCronJob()
|
||||
{
|
||||
// Arrange — Change backup schedule to every 6 hours with 30-day retention.
|
||||
|
||||
MongoCluster mongoCluster = MongoCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
mongoCluster.MarkRunning();
|
||||
await repository.AddAsync(mongoCluster);
|
||||
|
||||
ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
ConfigureMongoClusterRequest request = new(
|
||||
MongoClusterId: mongoCluster.Id,
|
||||
Members: null,
|
||||
StorageSize: null,
|
||||
BackupSchedule: "0 */6 * * *",
|
||||
BackupRetentionDays: 30,
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id);
|
||||
updated!.BackupConfig!.Schedule.Should().Be("0 */6 * * *");
|
||||
updated.BackupConfig.RetentionDays.Should().Be(30);
|
||||
|
||||
mongoClient.Verify(c => c.ConfigureScheduledBackupAsync(
|
||||
"config", "ctx", "prod-mongo", "mongodb-system", "0 */6 * * *", 30,
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureCluster_NonExistent_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
ConfigureMongoClusterRequest request = new(
|
||||
Guid.NewGuid(), Members: 5, StorageSize: null,
|
||||
BackupSchedule: null, BackupRetentionDays: null,
|
||||
KubeConfig: "config", ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
// ─── Adopt De-duplication ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptCluster_WhenClusterAlreadyAdopted_SkipsDuplicate()
|
||||
{
|
||||
// Arrange — The platform already has a cluster adopted. Re-running
|
||||
// discovery should not create a duplicate but update databases.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
MongoCluster existing = MongoCluster.Adopt(
|
||||
Guid.NewGuid(), clusterId, "prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
|
||||
"minio:9000", "mongo-backups", "minio-creds",
|
||||
databases: new List<string> { "app_db" });
|
||||
|
||||
await repository.AddAsync(existing);
|
||||
|
||||
mongoClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredMongoCluster>
|
||||
{
|
||||
new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
|
||||
new List<string> { "app_db", "analytics_db" },
|
||||
"minio:9000", "mongo-backups", "minio-creds"),
|
||||
new("new-mongo", "mongodb-staging", "6.0.17", 1, "20Gi",
|
||||
new List<string> { "staging_db" },
|
||||
"minio:9000", "mongo-backups-staging", "minio-creds")
|
||||
});
|
||||
|
||||
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
AdoptMongoClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Only the new cluster is adopted, the existing one is updated.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
IReadOnlyList<MongoCluster> all = await repository.GetByClusterIdAsync(clusterId);
|
||||
all.Should().HaveCount(2);
|
||||
all.Should().ContainSingle(c => c.Name == "prod-mongo");
|
||||
all.Should().ContainSingle(c => c.Name == "new-mongo");
|
||||
|
||||
// The existing cluster's databases should be updated.
|
||||
|
||||
MongoCluster? updated = all.First(c => c.Name == "prod-mongo");
|
||||
updated.Databases.Should().Contain("analytics_db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptCluster_WithClusterWithoutBackup_AdoptsWithDefaults()
|
||||
{
|
||||
// Arrange — A MongoDBCommunity cluster with no MinIO backup configured.
|
||||
|
||||
mongoClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredMongoCluster>
|
||||
{
|
||||
new("dev-mongo", "default", "7.0.12", 1, "10Gi",
|
||||
new List<string> { "myapp" },
|
||||
null, null, null)
|
||||
});
|
||||
|
||||
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Cluster adopted with default MinIO settings.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().HaveCount(1);
|
||||
|
||||
IReadOnlyList<MongoCluster> all = await repository.GetAllAsync();
|
||||
all[0].Name.Should().Be("dev-mongo");
|
||||
all[0].Status.Should().Be(MongoClusterStatus.Running);
|
||||
}
|
||||
|
||||
// ─── Restore Backup ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreBackup_WithValidCluster_CallsMongoClient()
|
||||
{
|
||||
// Arrange — A running cluster with a backup we want to restore from.
|
||||
|
||||
MongoCluster mongoCluster = MongoCluster.Adopt(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret",
|
||||
databases: new List<string> { "app_db" });
|
||||
|
||||
await repository.AddAsync(mongoCluster);
|
||||
|
||||
RestoreMongoBackupHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
RestoreMongoBackupRequest request = new(
|
||||
MongoClusterId: mongoCluster.Id,
|
||||
BackupName: "prod-mongo-dump-20250510120000",
|
||||
RestoredClusterName: "prod-mongo-restored",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
TargetTime: null);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — A new cluster should be created from the backup.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
mongoClient.Verify(c => c.RestoreBackupAsync(
|
||||
"config", "ctx",
|
||||
It.Is<RestoreFromMongoBackupSpec>(s =>
|
||||
s.SourceClusterName == "prod-mongo" &&
|
||||
s.RestoredClusterName == "prod-mongo-restored" &&
|
||||
s.BackupName == "prod-mongo-dump-20250510120000" &&
|
||||
s.Namespace == "mongodb-system"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
// The restored cluster should be persisted as a new aggregate.
|
||||
|
||||
IReadOnlyList<MongoCluster> all = await repository.GetAllAsync();
|
||||
all.Should().HaveCount(2);
|
||||
all.Should().ContainSingle(c => c.Name == "prod-mongo-restored");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreBackup_WithPointInTimeRecovery_PassesTargetTime()
|
||||
{
|
||||
// Arrange — Restore to a specific point in time using oplog replay.
|
||||
|
||||
MongoCluster mongoCluster = MongoCluster.Adopt(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
|
||||
await repository.AddAsync(mongoCluster);
|
||||
|
||||
RestoreMongoBackupHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
DateTimeOffset targetTime = new(2025, 5, 10, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
RestoreMongoBackupRequest request = new(
|
||||
MongoClusterId: mongoCluster.Id,
|
||||
BackupName: null,
|
||||
RestoredClusterName: "prod-mongo-pitr",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
TargetTime: targetTime);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
mongoClient.Verify(c => c.RestoreBackupAsync(
|
||||
"config", "ctx",
|
||||
It.Is<RestoreFromMongoBackupSpec>(s =>
|
||||
s.TargetTime == targetTime &&
|
||||
s.RestoredClusterName == "prod-mongo-pitr"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreBackup_SourceClusterNotFound_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
RestoreMongoBackupHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
RestoreMongoBackupRequest request = new(
|
||||
Guid.NewGuid(), "backup-name", "restored-cluster", "config", "ctx", null);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
// ─── List Live Databases ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptCluster_WithLiveDatabases_IncludesAllDatabases()
|
||||
{
|
||||
// Arrange — Discovery from the CRD spec only finds configured databases,
|
||||
// but the live MongoDB has additional databases created via shell.
|
||||
|
||||
mongoClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredMongoCluster>
|
||||
{
|
||||
new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
|
||||
new List<string> { "app_db" },
|
||||
"minio:9000", "mongo-backups", "minio-creds")
|
||||
});
|
||||
|
||||
// The live database listing discovers additional databases.
|
||||
|
||||
mongoClient.Setup(c => c.ListLiveDatabasesAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
"prod-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "app_db", "analytics_db", "reporting_db" });
|
||||
|
||||
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
|
||||
|
||||
AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — All three databases should be imported.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
IReadOnlyList<MongoCluster> all = await repository.GetAllAsync();
|
||||
all[0].Databases.Should().HaveCount(3);
|
||||
all[0].Databases.Should().Contain("app_db");
|
||||
all[0].Databases.Should().Contain("analytics_db");
|
||||
all[0].Databases.Should().Contain("reporting_db");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.PostgresClusters;
|
||||
using EntKube.Provisioning.Features.PostgresClusters.AdoptPostgresCluster;
|
||||
using EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster;
|
||||
using EntKube.Provisioning.Features.PostgresClusters.AddDatabase;
|
||||
using EntKube.Provisioning.Features.PostgresClusters.UpgradePostgresCluster;
|
||||
using EntKube.Provisioning.Features.PostgresClusters.ConfigurePostgresCluster;
|
||||
using EntKube.Provisioning.Features.PostgresClusters.RestoreBackup;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for all PostgreSQL cluster management features. Each test verifies that
|
||||
/// the handler correctly orchestrates domain logic + K8s client interactions.
|
||||
/// </summary>
|
||||
public class PostgresClusterFeatureTests
|
||||
{
|
||||
private readonly InMemoryPostgresClusterRepository repository;
|
||||
private readonly InMemoryMinioInstanceRepository minioRepository;
|
||||
private readonly Mock<ICnpgClusterClient> cnpgClient;
|
||||
|
||||
public PostgresClusterFeatureTests()
|
||||
{
|
||||
repository = new InMemoryPostgresClusterRepository();
|
||||
minioRepository = new InMemoryMinioInstanceRepository();
|
||||
cnpgClient = new Mock<ICnpgClusterClient>();
|
||||
}
|
||||
|
||||
// ─── Create Cluster ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient()
|
||||
{
|
||||
// Arrange — A platform admin wants a new 3-instance PostgreSQL cluster
|
||||
// with WAL archiving to MinIO.
|
||||
|
||||
CreatePostgresClusterHandler handler = new(repository, cnpgClient.Object, minioRepository);
|
||||
|
||||
CreatePostgresClusterRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: Guid.NewGuid(),
|
||||
Name: "platform-db",
|
||||
Namespace: "cnpg-system",
|
||||
PostgresVersion: "16.4",
|
||||
Instances: 3,
|
||||
StorageSize: "50Gi",
|
||||
KubeConfig: "apiVersion: v1\nkind: Config",
|
||||
ContextName: "prod-ctx",
|
||||
MinioEndpoint: "minio.minio-system.svc:9000",
|
||||
MinioBucket: "cnpg-backups",
|
||||
MinioCredentialsSecret: "minio-cnpg-creds");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Cluster created in repository and K8s client called.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBe(Guid.Empty);
|
||||
|
||||
IReadOnlyList<PostgresCluster> clusters = await repository.GetAllAsync();
|
||||
clusters.Should().HaveCount(1);
|
||||
clusters[0].Name.Should().Be("platform-db");
|
||||
clusters[0].Status.Should().Be(PostgresClusterStatus.Provisioning);
|
||||
|
||||
cnpgClient.Verify(c => c.CreateClusterAsync(
|
||||
"apiVersion: v1\nkind: Config",
|
||||
"prod-ctx",
|
||||
It.Is<CnpgClusterSpec>(s =>
|
||||
s.Name == "platform-db" &&
|
||||
s.PostgresVersion == "16.4" &&
|
||||
s.Instances == 3 &&
|
||||
s.MinioEndpoint == "minio.minio-system.svc:9000"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCluster_WithMissingName_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
CreatePostgresClusterHandler handler = new(repository, cnpgClient.Object, minioRepository);
|
||||
|
||||
CreatePostgresClusterRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: Guid.NewGuid(),
|
||||
Name: "",
|
||||
Namespace: "ns",
|
||||
PostgresVersion: "16.4",
|
||||
Instances: 3,
|
||||
StorageSize: "50Gi",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
MinioEndpoint: "minio:9000",
|
||||
MinioBucket: "bucket",
|
||||
MinioCredentialsSecret: "secret");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("name");
|
||||
}
|
||||
|
||||
// ─── Adopt Cluster ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptCluster_WithDiscoveredClusters_ImportsAll()
|
||||
{
|
||||
// Arrange — The K8s cluster has 2 existing CNPG clusters. We adopt them
|
||||
// so the platform is aware of what's already running.
|
||||
|
||||
cnpgClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredPostgresCluster>
|
||||
{
|
||||
new("prod-db", "cnpg-system", "16.4", 3, "100Gi",
|
||||
new List<string> { "app_db", "analytics_db" },
|
||||
"minio.minio-system.svc:9000", "pg-backups", "minio-creds", true),
|
||||
new("staging-db", "cnpg-staging", "15.6", 1, "20Gi",
|
||||
new List<string> { "staging_db" },
|
||||
"minio.minio-system.svc:9000", "pg-backups-staging", "minio-creds", true)
|
||||
});
|
||||
|
||||
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
AdoptPostgresClustersRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: Guid.NewGuid(),
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Both clusters adopted and stored in Running state.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().HaveCount(2);
|
||||
|
||||
IReadOnlyList<PostgresCluster> all = await repository.GetAllAsync();
|
||||
all.Should().HaveCount(2);
|
||||
all[0].Status.Should().Be(PostgresClusterStatus.Running);
|
||||
all[0].Databases.Should().Contain("app_db");
|
||||
all[1].Databases.Should().Contain("staging_db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptCluster_WithNoClusters_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange — No CNPG clusters found on the K8s cluster.
|
||||
|
||||
cnpgClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredPostgresCluster>());
|
||||
|
||||
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ─── Add Database ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AddDatabase_ToRunningCluster_CreatesDatabaseOnK8s()
|
||||
{
|
||||
// Arrange — A running PostgreSQL cluster exists.
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
pgCluster.MarkRunning();
|
||||
await repository.AddAsync(pgCluster);
|
||||
|
||||
AddDatabaseHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
AddDatabaseRequest request = new(
|
||||
PostgresClusterId: pgCluster.Id,
|
||||
DatabaseName: "new_app_db",
|
||||
OwnerRole: "app_user",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id);
|
||||
updated!.Databases.Should().Contain("new_app_db");
|
||||
|
||||
cnpgClient.Verify(c => c.CreateDatabaseAsync(
|
||||
"config", "ctx", "prod-db", "cnpg-system", "new_app_db", "app_user",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddDatabase_ToNonExistentCluster_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
AddDatabaseHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
AddDatabaseRequest request = new(Guid.NewGuid(), "db", "user", "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
// ─── Upgrade Cluster ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpgradeCluster_WithValidVersion_InitiatesUpgradeOnK8s()
|
||||
{
|
||||
// Arrange — A running cluster at version 15.6.
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "15.6", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
pgCluster.MarkRunning();
|
||||
await repository.AddAsync(pgCluster);
|
||||
|
||||
cnpgClient.Setup(c => c.GetAvailableVersionsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "15.6", "15.7", "16.4", "16.5" });
|
||||
|
||||
UpgradePostgresClusterHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
UpgradePostgresClusterRequest request = new(
|
||||
PostgresClusterId: pgCluster.Id,
|
||||
TargetVersion: "16.4",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Domain transitions to Upgrading, K8s client called.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id);
|
||||
updated!.Status.Should().Be(PostgresClusterStatus.Upgrading);
|
||||
updated.TargetVersion.Should().Be("16.4");
|
||||
|
||||
cnpgClient.Verify(c => c.UpgradeClusterAsync(
|
||||
"config", "ctx", "prod-db", "cnpg-system", "16.4",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpgradeCluster_ToUnavailableVersion_ReturnsFailure()
|
||||
{
|
||||
// Arrange — Version 99.0 doesn't exist.
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
pgCluster.MarkRunning();
|
||||
await repository.AddAsync(pgCluster);
|
||||
|
||||
cnpgClient.Setup(c => c.GetAvailableVersionsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "16.4", "16.5" });
|
||||
|
||||
UpgradePostgresClusterHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
UpgradePostgresClusterRequest request = new(pgCluster.Id, "99.0", "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not available");
|
||||
}
|
||||
|
||||
// ─── Configure Cluster ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureCluster_ScaleInstances_UpdatesK8sAndDomain()
|
||||
{
|
||||
// Arrange — Scale a running cluster from 3 to 5 instances.
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
pgCluster.MarkRunning();
|
||||
await repository.AddAsync(pgCluster);
|
||||
|
||||
ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
ConfigurePostgresClusterRequest request = new(
|
||||
PostgresClusterId: pgCluster.Id,
|
||||
Instances: 5,
|
||||
StorageSize: null,
|
||||
BackupSchedule: null,
|
||||
BackupRetentionDays: null,
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id);
|
||||
updated!.Instances.Should().Be(5);
|
||||
|
||||
cnpgClient.Verify(c => c.UpdateClusterAsync(
|
||||
"config", "ctx",
|
||||
It.Is<CnpgClusterSpec>(s => s.Instances == 5),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureCluster_UpdateBackupSchedule_UpdatesScheduledBackup()
|
||||
{
|
||||
// Arrange — Change backup schedule to every 6 hours with 30-day retention.
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
pgCluster.MarkRunning();
|
||||
await repository.AddAsync(pgCluster);
|
||||
|
||||
ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
ConfigurePostgresClusterRequest request = new(
|
||||
PostgresClusterId: pgCluster.Id,
|
||||
Instances: null,
|
||||
StorageSize: null,
|
||||
BackupSchedule: "0 */6 * * *",
|
||||
BackupRetentionDays: 30,
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id);
|
||||
updated!.BackupConfig!.Schedule.Should().Be("0 */6 * * *");
|
||||
updated.BackupConfig.RetentionDays.Should().Be(30);
|
||||
|
||||
cnpgClient.Verify(c => c.ConfigureScheduledBackupAsync(
|
||||
"config", "ctx", "prod-db", "cnpg-system", "0 */6 * * *", 30,
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureCluster_NonExistent_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
ConfigurePostgresClusterRequest request = new(
|
||||
Guid.NewGuid(), Instances: 5, StorageSize: null,
|
||||
BackupSchedule: null, BackupRetentionDays: null,
|
||||
KubeConfig: "config", ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
// ─── Adopt De-duplication ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptCluster_WhenClusterAlreadyAdopted_SkipsDuplicate()
|
||||
{
|
||||
// Arrange — The platform already has a cluster with the same name and
|
||||
// namespace adopted. Re-running discovery should not create a duplicate.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
PostgresCluster existing = PostgresCluster.Adopt(
|
||||
Guid.NewGuid(), clusterId, "prod-db", "cnpg-system", "16.4", 3, "100Gi",
|
||||
"minio:9000", "pg-backups", "minio-creds",
|
||||
databases: new List<string> { "app_db" });
|
||||
|
||||
await repository.AddAsync(existing);
|
||||
|
||||
cnpgClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredPostgresCluster>
|
||||
{
|
||||
new("prod-db", "cnpg-system", "16.4", 3, "100Gi",
|
||||
new List<string> { "app_db", "analytics_db" },
|
||||
"minio:9000", "pg-backups", "minio-creds", true),
|
||||
new("new-db", "cnpg-staging", "15.8", 1, "20Gi",
|
||||
new List<string> { "staging_db" },
|
||||
"minio:9000", "pg-backups-staging", "minio-creds", true)
|
||||
});
|
||||
|
||||
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
AdoptPostgresClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Only the new cluster is adopted, the existing one is updated.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
IReadOnlyList<PostgresCluster> all = await repository.GetByClusterIdAsync(clusterId);
|
||||
all.Should().HaveCount(2);
|
||||
all.Should().ContainSingle(c => c.Name == "prod-db");
|
||||
all.Should().ContainSingle(c => c.Name == "new-db");
|
||||
|
||||
// The existing cluster's databases should be updated with newly discovered ones.
|
||||
|
||||
PostgresCluster? updated = all.First(c => c.Name == "prod-db");
|
||||
updated.Databases.Should().Contain("analytics_db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptCluster_WithClusterWithoutBackup_AdoptsWithDefaults()
|
||||
{
|
||||
// Arrange — A CNPG cluster that has no MinIO backup configured.
|
||||
// The platform should still adopt it with default backup settings.
|
||||
|
||||
cnpgClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredPostgresCluster>
|
||||
{
|
||||
new("dev-db", "default", "16.4", 1, "10Gi",
|
||||
new List<string> { "myapp" },
|
||||
null, null, null, false)
|
||||
});
|
||||
|
||||
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Cluster adopted with default MinIO settings.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().HaveCount(1);
|
||||
|
||||
IReadOnlyList<PostgresCluster> all = await repository.GetAllAsync();
|
||||
all[0].Name.Should().Be("dev-db");
|
||||
all[0].Status.Should().Be(PostgresClusterStatus.Running);
|
||||
}
|
||||
|
||||
// ─── Restore Backup ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreBackup_WithValidCluster_CallsCnpgClient()
|
||||
{
|
||||
// Arrange — A running cluster with a backup we want to restore from.
|
||||
// Restoring in CNPG means creating a new cluster from a recovery source.
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Adopt(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret",
|
||||
databases: new List<string> { "app_db" });
|
||||
|
||||
await repository.AddAsync(pgCluster);
|
||||
|
||||
RestoreBackupHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
RestoreBackupRequest request = new(
|
||||
PostgresClusterId: pgCluster.Id,
|
||||
BackupName: "prod-db-manual-20250510120000",
|
||||
RestoredClusterName: "prod-db-restored",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
TargetTime: null);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — A new cluster should be created from the backup.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
cnpgClient.Verify(c => c.RestoreBackupAsync(
|
||||
"config", "ctx",
|
||||
It.Is<RestoreFromBackupSpec>(s =>
|
||||
s.SourceClusterName == "prod-db" &&
|
||||
s.RestoredClusterName == "prod-db-restored" &&
|
||||
s.BackupName == "prod-db-manual-20250510120000" &&
|
||||
s.Namespace == "cnpg-system"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
// The restored cluster should be persisted as a new aggregate.
|
||||
|
||||
IReadOnlyList<PostgresCluster> all = await repository.GetAllAsync();
|
||||
all.Should().HaveCount(2);
|
||||
all.Should().ContainSingle(c => c.Name == "prod-db-restored");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreBackup_WithPointInTimeRecovery_PassesTargetTime()
|
||||
{
|
||||
// Arrange — Restore to a specific point in time (PITR).
|
||||
|
||||
PostgresCluster pgCluster = PostgresCluster.Adopt(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
|
||||
"minio:9000", "bucket", "secret");
|
||||
|
||||
await repository.AddAsync(pgCluster);
|
||||
|
||||
RestoreBackupHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
DateTimeOffset targetTime = new(2025, 5, 10, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
RestoreBackupRequest request = new(
|
||||
PostgresClusterId: pgCluster.Id,
|
||||
BackupName: null,
|
||||
RestoredClusterName: "prod-db-pitr",
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx",
|
||||
TargetTime: targetTime);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
cnpgClient.Verify(c => c.RestoreBackupAsync(
|
||||
"config", "ctx",
|
||||
It.Is<RestoreFromBackupSpec>(s =>
|
||||
s.TargetTime == targetTime &&
|
||||
s.RestoredClusterName == "prod-db-pitr"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreBackup_SourceClusterNotFound_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
RestoreBackupHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
RestoreBackupRequest request = new(
|
||||
Guid.NewGuid(), "backup-name", "restored-cluster", "config", "ctx", null);
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
// ─── List Live Databases ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptCluster_WithLiveDatabases_IncludesAllDatabases()
|
||||
{
|
||||
// Arrange — Discovery from the CNPG CR spec only finds the bootstrap
|
||||
// database, but the live cluster has additional databases created via SQL.
|
||||
// The adoption should include all of them.
|
||||
|
||||
cnpgClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredPostgresCluster>
|
||||
{
|
||||
new("prod-db", "cnpg-system", "16.4", 3, "100Gi",
|
||||
new List<string> { "app_db" },
|
||||
"minio:9000", "pg-backups", "minio-creds", true)
|
||||
});
|
||||
|
||||
// The live database listing discovers additional databases.
|
||||
|
||||
cnpgClient.Setup(c => c.ListLiveDatabasesAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
"prod-db", "cnpg-system", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "app_db", "analytics_db", "reporting_db" });
|
||||
|
||||
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
|
||||
|
||||
AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — All databases from the live cluster are included.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
|
||||
IReadOnlyList<PostgresCluster> all = await repository.GetAllAsync();
|
||||
all[0].Databases.Should().HaveCount(3);
|
||||
all[0].Databases.Should().Contain("app_db");
|
||||
all[0].Databases.Should().Contain("analytics_db");
|
||||
all[0].Databases.Should().Contain("reporting_db");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.RedisClusters;
|
||||
using EntKube.Provisioning.Features.RedisClusters.AdoptRedisCluster;
|
||||
using EntKube.Provisioning.Features.RedisClusters.CreateRedisCluster;
|
||||
using EntKube.Provisioning.Features.RedisClusters.UpgradeRedisCluster;
|
||||
using EntKube.Provisioning.Features.RedisClusters.ConfigureRedisCluster;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for all Redis cluster management features. Each test verifies that
|
||||
/// the handler correctly orchestrates domain logic + K8s client interactions.
|
||||
/// </summary>
|
||||
public class RedisClusterFeatureTests
|
||||
{
|
||||
private readonly InMemoryRedisClusterRepository repository;
|
||||
private readonly Mock<IRedisClusterClient> redisClient;
|
||||
|
||||
public RedisClusterFeatureTests()
|
||||
{
|
||||
repository = new InMemoryRedisClusterRepository();
|
||||
redisClient = new Mock<IRedisClusterClient>();
|
||||
}
|
||||
|
||||
// ─── Create Cluster ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient()
|
||||
{
|
||||
// Arrange — A platform admin wants a new 3-replica Redis instance
|
||||
// with sentinel enabled for HA.
|
||||
|
||||
CreateRedisClusterHandler handler = new(repository, redisClient.Object);
|
||||
|
||||
CreateRedisClusterRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: Guid.NewGuid(),
|
||||
Name: "platform-cache",
|
||||
Namespace: "redis",
|
||||
RedisVersion: "7.2.5",
|
||||
Replicas: 3,
|
||||
StorageSize: "10Gi",
|
||||
SentinelEnabled: true,
|
||||
KubeConfig: "apiVersion: v1\nkind: Config",
|
||||
ContextName: "prod-ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Cluster created in repository and K8s client called.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBe(Guid.Empty);
|
||||
|
||||
IReadOnlyList<RedisCluster> clusters = await repository.GetAllAsync();
|
||||
clusters.Should().HaveCount(1);
|
||||
clusters[0].Name.Should().Be("platform-cache");
|
||||
clusters[0].Status.Should().Be(RedisClusterStatus.Provisioning);
|
||||
|
||||
redisClient.Verify(c => c.CreateClusterAsync(
|
||||
"apiVersion: v1\nkind: Config",
|
||||
"prod-ctx",
|
||||
It.Is<RedisClusterSpec>(s =>
|
||||
s.Name == "platform-cache" &&
|
||||
s.RedisVersion == "7.2.5" &&
|
||||
s.Replicas == 3 &&
|
||||
s.SentinelEnabled == true),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCluster_WithMissingName_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
CreateRedisClusterHandler handler = new(repository, redisClient.Object);
|
||||
|
||||
CreateRedisClusterRequest request = new(
|
||||
EnvironmentId: Guid.NewGuid(),
|
||||
ClusterId: Guid.NewGuid(),
|
||||
Name: "",
|
||||
Namespace: "redis",
|
||||
RedisVersion: "7.2.5",
|
||||
Replicas: 3,
|
||||
StorageSize: "10Gi",
|
||||
SentinelEnabled: true,
|
||||
KubeConfig: "config",
|
||||
ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<Guid> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Domain validation rejects empty names.
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("name");
|
||||
}
|
||||
|
||||
// ─── Adopt Clusters ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptClusters_DiscoversTwoInstances_CreatesBothInRunningState()
|
||||
{
|
||||
// Arrange — The K8s cluster has two existing Redis instances.
|
||||
|
||||
redisClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredRedisCluster>
|
||||
{
|
||||
new("session-cache", "redis", "7.2.5", 3, "10Gi", true),
|
||||
new("rate-limiter", "redis", "7.0.15", 1, "5Gi", false)
|
||||
});
|
||||
|
||||
AdoptRedisClustersHandler handler = new(repository, redisClient.Object);
|
||||
|
||||
AdoptRedisClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Both clusters adopted.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().HaveCount(2);
|
||||
|
||||
IReadOnlyList<RedisCluster> clusters = await repository.GetAllAsync();
|
||||
clusters.Should().HaveCount(2);
|
||||
clusters.Should().OnlyContain(c => c.Status == RedisClusterStatus.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdoptClusters_AlreadyAdopted_ReturnsExistingIdWithoutDuplicate()
|
||||
{
|
||||
// Arrange — One instance already adopted.
|
||||
|
||||
Guid clusterId = Guid.NewGuid();
|
||||
|
||||
RedisCluster existing = RedisCluster.Adopt(
|
||||
Guid.NewGuid(), clusterId, "session-cache", "redis", "7.2.5", 3, "10Gi", true);
|
||||
await repository.AddAsync(existing);
|
||||
|
||||
redisClient.Setup(c => c.DiscoverClustersAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DiscoveredRedisCluster>
|
||||
{
|
||||
new("session-cache", "redis", "7.2.5", 3, "10Gi", true)
|
||||
});
|
||||
|
||||
AdoptRedisClustersHandler handler = new(repository, redisClient.Object);
|
||||
|
||||
AdoptRedisClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<List<Guid>> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert — Returns existing ID without creating a duplicate.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().HaveCount(1);
|
||||
result.Value![0].Should().Be(existing.Id);
|
||||
|
||||
IReadOnlyList<RedisCluster> clusters = await repository.GetAllAsync();
|
||||
clusters.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
// ─── Upgrade ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpgradeCluster_WithValidVersion_TransitionsToUpgrading()
|
||||
{
|
||||
// Arrange — A running Redis instance at version 7.0.15.
|
||||
|
||||
RedisCluster redisCluster = RedisCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.0.15", 3, "10Gi", true);
|
||||
redisCluster.MarkRunning();
|
||||
await repository.AddAsync(redisCluster);
|
||||
|
||||
redisClient.Setup(c => c.GetAvailableVersionsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "7.0.15", "7.2.5" });
|
||||
|
||||
UpgradeRedisClusterHandler handler = new(repository, redisClient.Object);
|
||||
|
||||
UpgradeRedisClusterRequest request = new(redisCluster.Id, "7.2.5", "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
redisCluster.Status.Should().Be(RedisClusterStatus.Upgrading);
|
||||
redisCluster.TargetVersion.Should().Be("7.2.5");
|
||||
|
||||
redisClient.Verify(c => c.UpgradeClusterAsync(
|
||||
"config", "ctx", "cache", "redis", "7.2.5",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpgradeCluster_UnavailableVersion_ReturnsFailure()
|
||||
{
|
||||
// Arrange — Target version not in the available list.
|
||||
|
||||
RedisCluster redisCluster = RedisCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.0.15", 3, "10Gi", true);
|
||||
redisCluster.MarkRunning();
|
||||
await repository.AddAsync(redisCluster);
|
||||
|
||||
redisClient.Setup(c => c.GetAvailableVersionsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "7.0.15" });
|
||||
|
||||
UpgradeRedisClusterHandler handler = new(repository, redisClient.Object);
|
||||
|
||||
UpgradeRedisClusterRequest request = new(redisCluster.Id, "7.2.5", "config", "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not available");
|
||||
}
|
||||
|
||||
// ─── Configure ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureCluster_ScaleReplicas_UpdatesAndCallsK8s()
|
||||
{
|
||||
// Arrange — A running 3-replica cluster to be scaled to 5.
|
||||
|
||||
RedisCluster redisCluster = RedisCluster.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.2.5", 3, "10Gi", true);
|
||||
redisCluster.MarkRunning();
|
||||
await repository.AddAsync(redisCluster);
|
||||
|
||||
ConfigureRedisClusterHandler handler = new(repository, redisClient.Object);
|
||||
|
||||
ConfigureRedisClusterRequest request = new(
|
||||
redisCluster.Id, Replicas: 5, StorageSize: null, SentinelEnabled: null,
|
||||
KubeConfig: "config", ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
redisCluster.Replicas.Should().Be(5);
|
||||
|
||||
redisClient.Verify(c => c.UpdateClusterAsync(
|
||||
"config", "ctx",
|
||||
It.Is<RedisClusterSpec>(s => s.Replicas == 5),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureCluster_NonExistent_ReturnsFailure()
|
||||
{
|
||||
// Arrange — No cluster with this ID.
|
||||
|
||||
ConfigureRedisClusterHandler handler = new(repository, redisClient.Object);
|
||||
|
||||
ConfigureRedisClusterRequest request = new(
|
||||
Guid.NewGuid(), Replicas: 5, StorageSize: null, SentinelEnabled: null,
|
||||
KubeConfig: "config", ContextName: "ctx");
|
||||
|
||||
// Act
|
||||
|
||||
Result<string> result = await handler.HandleAsync(request);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.Apps.RemoveAppEnvironment;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class RemoveAppEnvironmentHandlerTests
|
||||
{
|
||||
private readonly InMemoryAppRepository repository;
|
||||
private readonly RemoveAppEnvironmentHandler handler;
|
||||
|
||||
public RemoveAppEnvironmentHandlerTests()
|
||||
{
|
||||
repository = new InMemoryAppRepository();
|
||||
handler = new RemoveAppEnvironmentHandler(repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_ExistingEnvironment_RemovesSuccessfully()
|
||||
{
|
||||
// Arrange — An app with one environment.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid envId = Guid.NewGuid();
|
||||
app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(app.Id, envId));
|
||||
|
||||
// Assert — The environment should be removed.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
updated!.Environments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
|
||||
{
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(Guid.NewGuid(), Guid.NewGuid()));
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_NonexistentEnvironment_ReturnsFailure()
|
||||
{
|
||||
// Arrange — An app with no environments.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(app.Id, Guid.NewGuid()));
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.Apps.RemoveAppSecret;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class RemoveAppSecretHandlerTests
|
||||
{
|
||||
private readonly InMemoryAppRepository repository;
|
||||
private readonly RemoveAppSecretHandler handler;
|
||||
|
||||
public RemoveAppSecretHandlerTests()
|
||||
{
|
||||
repository = new InMemoryAppRepository();
|
||||
handler = new RemoveAppSecretHandler(repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_ExistingSecret_RemovesSuccessfully()
|
||||
{
|
||||
// Arrange — An app with an environment that has a secret.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid envId = Guid.NewGuid();
|
||||
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
|
||||
env.AddSecret("DATABASE_URL", "db-conn");
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(
|
||||
new RemoveAppSecretRequest(app.Id, envId, "DATABASE_URL"));
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
updated!.Environments[0].Secrets.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
|
||||
{
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(
|
||||
new RemoveAppSecretRequest(Guid.NewGuid(), Guid.NewGuid(), "KEY"));
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_NonexistentSecret_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid envId = Guid.NewGuid();
|
||||
app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(
|
||||
new RemoveAppSecretRequest(app.Id, envId, "MISSING"));
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.Apps.SuspendActivateApp;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class SuspendActivateAppHandlerTests
|
||||
{
|
||||
private readonly InMemoryAppRepository repository;
|
||||
private readonly SuspendActivateAppHandler handler;
|
||||
|
||||
public SuspendActivateAppHandlerTests()
|
||||
{
|
||||
repository = new InMemoryAppRepository();
|
||||
handler = new SuspendActivateAppHandler(repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuspendAsync_ActiveApp_SuspendsSuccessfully()
|
||||
{
|
||||
// Arrange — An active app.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.SuspendAsync(app.Id);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
updated!.Status.Should().Be(AppStatus.Suspended);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_SuspendedApp_ActivatesSuccessfully()
|
||||
{
|
||||
// Arrange — A suspended app.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
app.Suspend();
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.ActivateAsync(app.Id);
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
updated!.Status.Should().Be(AppStatus.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuspendAsync_NonexistentApp_ReturnsFailure()
|
||||
{
|
||||
// Act
|
||||
|
||||
Result result = await handler.SuspendAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using EntKube.Provisioning.Domain;
|
||||
using EntKube.Provisioning.Features.Apps.TriggerSync;
|
||||
using EntKube.Provisioning.Infrastructure;
|
||||
using EntKube.SharedKernel.Domain;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace EntKube.Provisioning.Tests.Features;
|
||||
|
||||
public class TriggerSyncHandlerTests
|
||||
{
|
||||
private readonly InMemoryAppRepository repository;
|
||||
private readonly TriggerSyncHandler handler;
|
||||
|
||||
public TriggerSyncHandlerTests()
|
||||
{
|
||||
repository = new InMemoryAppRepository();
|
||||
handler = new TriggerSyncHandler(repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_SyncedEnvironment_MarksPending()
|
||||
{
|
||||
// Arrange — An environment that was previously synced. The user
|
||||
// wants to force a re-sync (e.g., after a config change outside
|
||||
// the UI or to retry after fixing a problem).
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid envId = Guid.NewGuid();
|
||||
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
|
||||
|
||||
env.ConfigureDeployment(new DeploymentSpec(
|
||||
Image: "api", Tag: "v1", Replicas: 1,
|
||||
ContainerPort: 8080, ServicePort: 80,
|
||||
HostName: null, PathPrefix: null,
|
||||
EnvironmentVariables: null, Resources: null));
|
||||
|
||||
env.MarkSynced();
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(new TriggerSyncRequest(app.Id, envId));
|
||||
|
||||
// Assert — Should be marked Pending so the reconciler picks it up.
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_ErrorEnvironment_MarksPendingForRetry()
|
||||
{
|
||||
// Arrange — An environment in error state. The admin fixes the issue
|
||||
// and wants to retry the sync.
|
||||
|
||||
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
|
||||
Guid envId = Guid.NewGuid();
|
||||
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
|
||||
|
||||
env.ConfigureDeployment(new DeploymentSpec(
|
||||
Image: "api", Tag: "v1", Replicas: 1,
|
||||
ContainerPort: 8080, ServicePort: 80,
|
||||
HostName: null, PathPrefix: null,
|
||||
EnvironmentVariables: null, Resources: null));
|
||||
|
||||
env.MarkError("Connection refused");
|
||||
await repository.AddAsync(app);
|
||||
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(new TriggerSyncRequest(app.Id, envId));
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
App? updated = await repository.GetByIdAsync(app.Id);
|
||||
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
|
||||
{
|
||||
// Act
|
||||
|
||||
Result result = await handler.HandleAsync(
|
||||
new TriggerSyncRequest(Guid.NewGuid(), Guid.NewGuid()));
|
||||
|
||||
// Assert
|
||||
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user