too much for one commit

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

View File

@@ -0,0 +1,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");
}
}

View 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*");
}
}

View 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);
}
}

View 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);
}
}

View 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" });
}
}

View 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);
}
}

View 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");
}
}

View 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");
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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);
}
}