388 lines
15 KiB
C#
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);
|
|
}
|
|
}
|