Files
Entkube/tests/EntKube.Provisioning.Tests/Features/AppReconcilerTests.cs
2026-05-13 14:01:32 +02:00

388 lines
15 KiB
C#

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