729 lines
32 KiB
C#
729 lines
32 KiB
C#
using EntKube.Clusters.Domain;
|
|
using EntKube.Clusters.Features.AdoptCluster;
|
|
using EntKube.Clusters.Features.AdoptCluster.Components;
|
|
using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
|
|
using EntKube.Clusters.Features.AdoptCluster.DeployComponent;
|
|
using EntKube.Clusters.Infrastructure;
|
|
using EntKube.SharedKernel.Domain;
|
|
using FluentAssertions;
|
|
using k8s.Models;
|
|
using Moq;
|
|
|
|
namespace EntKube.Clusters.Tests.Features;
|
|
|
|
/// <summary>
|
|
/// Tests for the Harbor container registry component. Harbor provides:
|
|
///
|
|
/// 1. **Container registry** — hosting private container images
|
|
/// 2. **Helm chart repository** — hosting Helm charts via OCI
|
|
/// 3. **Pull-through cache** — caching upstream registries (Docker Hub, ghcr.io, quay.io, etc.)
|
|
///
|
|
/// The pull-through cache is especially valuable: all services and tenant apps
|
|
/// pull images through Harbor, gaining caching, vulnerability scanning, and
|
|
/// network isolation from public registries.
|
|
///
|
|
/// Dependency chain: ingress (for Harbor UI/API) → cert-manager (TLS) → Harbor
|
|
/// </summary>
|
|
public class HarborTests
|
|
{
|
|
private readonly InMemoryClusterRepository repository;
|
|
private readonly Mock<IComponentInstaller> harborInstaller;
|
|
|
|
public HarborTests()
|
|
{
|
|
repository = new InMemoryClusterRepository();
|
|
|
|
harborInstaller = new Mock<IComponentInstaller>();
|
|
harborInstaller.Setup(i => i.ComponentName).Returns("harbor");
|
|
}
|
|
|
|
// ─── Harbor Installation ──────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Harbor_Install_DeploysRegistryWithPullThroughCache()
|
|
{
|
|
// Arrange — We want a full Harbor deployment with pull-through proxy cache
|
|
// enabled for major upstream registries. This gives us a local mirror that
|
|
// speeds up pulls and survives upstream outages.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
harborInstaller
|
|
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new InstallResult(
|
|
Success: true,
|
|
ComponentName: "harbor",
|
|
Message: "Harbor 2.12.0 installed with pull-through cache for docker.io, ghcr.io, quay.io",
|
|
Actions: new List<string>
|
|
{
|
|
"Ensured namespace 'harbor'",
|
|
"Helm install harbor v2.12.0",
|
|
"Created proxy cache project 'dockerhub-cache' → docker.io",
|
|
"Created proxy cache project 'ghcr-cache' → ghcr.io",
|
|
"Created proxy cache project 'quay-cache' → quay.io",
|
|
"Harbor registry available at registry.internal.corp.com"
|
|
}));
|
|
|
|
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
|
|
{
|
|
harborInstaller.Object
|
|
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
|
|
|
|
DeployComponentRequest request = new("harbor", Version: "2.12.0",
|
|
Parameters: new Dictionary<string, string>
|
|
{
|
|
["harborDomain"] = "registry.internal.corp.com",
|
|
["adminPassword"] = "Harbor12345",
|
|
["storageClass"] = "longhorn",
|
|
["storageSize"] = "100Gi",
|
|
["cacheRegistries"] = "docker.io,ghcr.io,quay.io",
|
|
["enableChartMuseum"] = "true"
|
|
});
|
|
|
|
// Act
|
|
|
|
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
|
|
|
|
// Assert — Harbor deployed with proxy cache projects for each registry.
|
|
|
|
result.IsSuccess.Should().BeTrue();
|
|
result.Value!.Actions.Should().Contain(a => a.Contains("Helm install harbor"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("dockerhub-cache"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("ghcr-cache"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("quay-cache"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("registry.internal.corp.com"));
|
|
|
|
harborInstaller.Verify(i => i.InstallAsync(
|
|
cluster,
|
|
It.Is<ComponentInstallOptions>(o =>
|
|
o.Parameters!["harborDomain"] == "registry.internal.corp.com" &&
|
|
o.Parameters["cacheRegistries"] == "docker.io,ghcr.io,quay.io"),
|
|
It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Harbor_Install_WithTLS_UsesClusterIssuerForCertificate()
|
|
{
|
|
// Arrange — Harbor should use a cert-manager ClusterIssuer to auto-provision
|
|
// TLS certificates. This integrates with the internal CA or domain CA
|
|
// we set up earlier.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
harborInstaller
|
|
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new InstallResult(
|
|
Success: true,
|
|
ComponentName: "harbor",
|
|
Message: "Harbor 2.12.0 installed with TLS via ClusterIssuer 'platform-internal-ca'",
|
|
Actions: new List<string>
|
|
{
|
|
"Ensured namespace 'harbor'",
|
|
"Helm install harbor v2.12.0",
|
|
"Configured TLS via ClusterIssuer 'platform-internal-ca'",
|
|
"Created Ingress for registry.internal.corp.com with TLS",
|
|
"Harbor registry available at registry.internal.corp.com"
|
|
}));
|
|
|
|
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
|
|
{
|
|
harborInstaller.Object
|
|
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
|
|
|
|
DeployComponentRequest request = new("harbor", Version: "2.12.0",
|
|
Parameters: new Dictionary<string, string>
|
|
{
|
|
["harborDomain"] = "registry.internal.corp.com",
|
|
["adminPassword"] = "Harbor12345",
|
|
["storageClass"] = "longhorn",
|
|
["storageSize"] = "50Gi",
|
|
["tlsIssuer"] = "platform-internal-ca",
|
|
["enableTLS"] = "true"
|
|
});
|
|
|
|
// Act
|
|
|
|
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
|
|
|
|
// Assert — TLS configured via cert-manager integration.
|
|
|
|
result.IsSuccess.Should().BeTrue();
|
|
result.Value!.Actions.Should().Contain(a => a.Contains("TLS via ClusterIssuer"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("Ingress"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Harbor_Install_WithoutPrerequisites_ReportsFailure()
|
|
{
|
|
// Arrange — Harbor requires ingress to be accessible. If the cluster
|
|
// has no ingress controller, the install should fail gracefully.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
harborInstaller
|
|
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new InstallResult(
|
|
Success: false,
|
|
ComponentName: "harbor",
|
|
Message: "Harbor requires an ingress controller but none was detected",
|
|
Actions: new List<string> { "Prerequisite check failed: no ingress controller found" }));
|
|
|
|
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
|
|
{
|
|
harborInstaller.Object
|
|
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
|
|
|
|
DeployComponentRequest request = new("harbor",
|
|
Parameters: new Dictionary<string, string>
|
|
{
|
|
["harborDomain"] = "registry.internal.corp.com",
|
|
["adminPassword"] = "Harbor12345"
|
|
});
|
|
|
|
// Act
|
|
|
|
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
|
|
|
|
// Assert — Prerequisite failure reported.
|
|
|
|
result.IsFailure.Should().BeTrue();
|
|
result.Error.Should().Contain("ingress");
|
|
}
|
|
|
|
// ─── Harbor Configuration ─────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Harbor_Configure_AddProxyCacheRegistry()
|
|
{
|
|
// Arrange — Harbor is running. We want to add a new upstream registry
|
|
// as a proxy cache (e.g., registry.k8s.io for Kubernetes images).
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
harborInstaller
|
|
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new InstallResult(
|
|
Success: true,
|
|
ComponentName: "harbor",
|
|
Message: "Proxy cache project added for registry.k8s.io",
|
|
Actions: new List<string>
|
|
{
|
|
"Created proxy cache project 'k8s-registry-cache' → registry.k8s.io",
|
|
"Proxy cache now covers: docker.io, ghcr.io, quay.io, registry.k8s.io"
|
|
}));
|
|
|
|
List<IComponentInstaller> installers = new() { harborInstaller.Object };
|
|
ConfigureComponentHandler configHandler = new(repository, installers);
|
|
|
|
ConfigureComponentRequest configRequest = new(
|
|
ComponentName: "harbor",
|
|
Values: new Dictionary<string, string>
|
|
{
|
|
["addProxyCache"] = "registry.k8s.io",
|
|
["proxyCacheProjectName"] = "k8s-registry-cache"
|
|
});
|
|
|
|
// Act
|
|
|
|
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
|
|
|
|
// Assert — New proxy cache project created.
|
|
|
|
result.IsSuccess.Should().BeTrue();
|
|
result.Value!.Actions.Should().Contain(a => a.Contains("registry.k8s.io"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("k8s-registry-cache"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Harbor_Configure_SetAsDefaultPullThroughCache()
|
|
{
|
|
// Arrange — We want to configure the cluster so all pod image pulls
|
|
// go through Harbor as a mirror/cache. This typically involves configuring
|
|
// containerd mirror settings or using Kyverno to mutate image references.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
harborInstaller
|
|
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new InstallResult(
|
|
Success: true,
|
|
ComponentName: "harbor",
|
|
Message: "Cluster configured to use Harbor as default pull-through cache",
|
|
Actions: new List<string>
|
|
{
|
|
"Created Kyverno ClusterPolicy 'harbor-image-mirror' to rewrite image references",
|
|
"Policy rewrites docker.io/* → registry.internal.corp.com/dockerhub-cache/*",
|
|
"Policy rewrites ghcr.io/* → registry.internal.corp.com/ghcr-cache/*",
|
|
"Policy rewrites quay.io/* → registry.internal.corp.com/quay-cache/*"
|
|
}));
|
|
|
|
List<IComponentInstaller> installers = new() { harborInstaller.Object };
|
|
ConfigureComponentHandler configHandler = new(repository, installers);
|
|
|
|
ConfigureComponentRequest configRequest = new(
|
|
ComponentName: "harbor",
|
|
Values: new Dictionary<string, string>
|
|
{
|
|
["setDefaultCache"] = "true",
|
|
["harborDomain"] = "registry.internal.corp.com",
|
|
["cacheStrategy"] = "kyverno-rewrite"
|
|
});
|
|
|
|
// Act
|
|
|
|
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
|
|
|
|
// Assert — Kyverno policy created to rewrite image refs through Harbor.
|
|
|
|
result.IsSuccess.Should().BeTrue();
|
|
result.Value!.Actions.Should().Contain(a => a.Contains("ClusterPolicy"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("docker.io"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("ghcr.io"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Harbor_Configure_CreatePrivateProject()
|
|
{
|
|
// Arrange — Create a private project in Harbor for tenant images.
|
|
// Each tenant gets their own project with isolation.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
harborInstaller
|
|
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new InstallResult(
|
|
Success: true,
|
|
ComponentName: "harbor",
|
|
Message: "Private project 'tenant-alpha' created",
|
|
Actions: new List<string>
|
|
{
|
|
"Created private project 'tenant-alpha'",
|
|
"Created robot account 'robot$tenant-alpha-pull' with pull access",
|
|
"Created image pull Secret 'harbor-pull-tenant-alpha' in namespace 'tenant-alpha'"
|
|
}));
|
|
|
|
List<IComponentInstaller> installers = new() { harborInstaller.Object };
|
|
ConfigureComponentHandler configHandler = new(repository, installers);
|
|
|
|
ConfigureComponentRequest configRequest = new(
|
|
ComponentName: "harbor",
|
|
Values: new Dictionary<string, string>
|
|
{
|
|
["createProject"] = "tenant-alpha",
|
|
["projectVisibility"] = "private",
|
|
["createPullSecret"] = "true",
|
|
["targetNamespace"] = "tenant-alpha"
|
|
});
|
|
|
|
// Act
|
|
|
|
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
|
|
|
|
// Assert — Project, robot account, and pull secret created.
|
|
|
|
result.IsSuccess.Should().BeTrue();
|
|
result.Value!.Actions.Should().Contain(a => a.Contains("private project 'tenant-alpha'"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("robot account"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("pull Secret"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Harbor_Configure_EnableVulnerabilityScanning()
|
|
{
|
|
// Arrange — Enable automatic vulnerability scanning on all pushed images.
|
|
// Harbor uses Trivy for scanning. We want to configure the scan policy.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
harborInstaller
|
|
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new InstallResult(
|
|
Success: true,
|
|
ComponentName: "harbor",
|
|
Message: "Vulnerability scanning configured",
|
|
Actions: new List<string>
|
|
{
|
|
"Enabled automatic scan on push for all projects",
|
|
"Set vulnerability severity threshold to 'High'",
|
|
"Configured scan schedule: daily at 02:00 UTC"
|
|
}));
|
|
|
|
List<IComponentInstaller> installers = new() { harborInstaller.Object };
|
|
ConfigureComponentHandler configHandler = new(repository, installers);
|
|
|
|
ConfigureComponentRequest configRequest = new(
|
|
ComponentName: "harbor",
|
|
Values: new Dictionary<string, string>
|
|
{
|
|
["enableScanOnPush"] = "true",
|
|
["severityThreshold"] = "High",
|
|
["scanSchedule"] = "0 2 * * *"
|
|
});
|
|
|
|
// Act
|
|
|
|
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
|
|
|
|
// Assert — Scanning policies configured.
|
|
|
|
result.IsSuccess.Should().BeTrue();
|
|
result.Value!.Actions.Should().Contain(a => a.Contains("scan on push"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("severity threshold"));
|
|
}
|
|
|
|
// ─── Wire All Platform Services ──────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Harbor_Configure_WireAllServices_CreatesComprehensiveCacheAndPolicy()
|
|
{
|
|
// Arrange — Harbor is running. We want to wire ALL platform services
|
|
// (MinIO, CloudNativePG, cert-manager, Kyverno, Istio, Grafana, Prometheus,
|
|
// Redis, RabbitMQ, External Secrets) to pull images through Harbor.
|
|
// This creates proxy cache projects for every upstream registry used by
|
|
// our services and a Kyverno policy to rewrite image references.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
harborInstaller
|
|
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new InstallResult(
|
|
Success: true,
|
|
ComponentName: "harbor",
|
|
Message: "All platform services wired to pull through Harbor",
|
|
Actions: new List<string>
|
|
{
|
|
"Ensured proxy cache project 'dockerhub-cache' → docker.io (MinIO, Istio, Grafana, Redis, RabbitMQ)",
|
|
"Ensured proxy cache project 'ghcr-cache' → ghcr.io (CloudNativePG, Kyverno, External Secrets)",
|
|
"Ensured proxy cache project 'quay-cache' → quay.io (cert-manager, trust-manager, Prometheus)",
|
|
"Ensured proxy cache project 'k8s-cache' → registry.k8s.io (CoreDNS, metrics-server)",
|
|
"Ensured proxy cache project 'gcr-cache' → gcr.io (Istio release images)",
|
|
"Created Kyverno ClusterPolicy 'harbor-image-mirror' to rewrite image references",
|
|
"Policy rewrites docker.io/* → registry.internal.corp.com/dockerhub-cache/*",
|
|
"Policy rewrites ghcr.io/* → registry.internal.corp.com/ghcr-cache/*",
|
|
"Policy rewrites quay.io/* → registry.internal.corp.com/quay-cache/*",
|
|
"Policy rewrites registry.k8s.io/* → registry.internal.corp.com/k8s-cache/*",
|
|
"Policy rewrites gcr.io/* → registry.internal.corp.com/gcr-cache/*",
|
|
"All 12 platform services now pull through Harbor"
|
|
}));
|
|
|
|
List<IComponentInstaller> installers = new() { harborInstaller.Object };
|
|
ConfigureComponentHandler configHandler = new(repository, installers);
|
|
|
|
ConfigureComponentRequest configRequest = new(
|
|
ComponentName: "harbor",
|
|
Values: new Dictionary<string, string>
|
|
{
|
|
["wireAllServices"] = "true",
|
|
["harborDomain"] = "registry.internal.corp.com"
|
|
});
|
|
|
|
// Act
|
|
|
|
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
|
|
|
|
// Assert — All registries cached and policy covers all platforms.
|
|
|
|
result.IsSuccess.Should().BeTrue();
|
|
result.Value!.Actions.Should().Contain(a => a.Contains("docker.io") && a.Contains("MinIO"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("ghcr.io") && a.Contains("CloudNativePG"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("quay.io") && a.Contains("cert-manager"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("registry.k8s.io"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("gcr.io"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("ClusterPolicy"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("12 platform services"));
|
|
|
|
harborInstaller.Verify(i => i.ConfigureAsync(
|
|
cluster,
|
|
It.Is<ComponentConfiguration>(c =>
|
|
c.Values["wireAllServices"] == "true" &&
|
|
c.Values["harborDomain"] == "registry.internal.corp.com"),
|
|
It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Harbor_Install_WithAllServices_IncludesAllRegistryCaches()
|
|
{
|
|
// Arrange — When installing Harbor with wireAllServices=true, it should
|
|
// automatically create proxy caches for all 5 upstream registries
|
|
// needed by platform services, not just the default 3.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
harborInstaller
|
|
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new InstallResult(
|
|
Success: true,
|
|
ComponentName: "harbor",
|
|
Message: "Harbor 2.12.0 installed with pull-through cache for all platform registries",
|
|
Actions: new List<string>
|
|
{
|
|
"Ensured namespace 'harbor'",
|
|
"Helm install harbor v2.12.0",
|
|
"Created proxy cache project 'dockerhub-cache' → docker.io",
|
|
"Created proxy cache project 'ghcr-cache' → ghcr.io",
|
|
"Created proxy cache project 'quay-cache' → quay.io",
|
|
"Created proxy cache project 'k8s-cache' → registry.k8s.io",
|
|
"Created proxy cache project 'gcr-cache' → gcr.io",
|
|
"Harbor registry available at registry.internal.corp.com"
|
|
}));
|
|
|
|
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
|
|
{
|
|
harborInstaller.Object
|
|
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
|
|
|
|
DeployComponentRequest request = new("harbor", Version: "2.12.0",
|
|
Parameters: new Dictionary<string, string>
|
|
{
|
|
["harborDomain"] = "registry.internal.corp.com",
|
|
["adminPassword"] = "Harbor12345",
|
|
["storageClass"] = "longhorn",
|
|
["storageSize"] = "100Gi",
|
|
["cacheRegistries"] = "docker.io,ghcr.io,quay.io,registry.k8s.io,gcr.io"
|
|
});
|
|
|
|
// Act
|
|
|
|
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
|
|
|
|
// Assert — All 5 registries have proxy cache projects.
|
|
|
|
result.IsSuccess.Should().BeTrue();
|
|
result.Value!.Actions.Should().Contain(a => a.Contains("dockerhub-cache"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("ghcr-cache"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("quay-cache"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("k8s-cache"));
|
|
result.Value.Actions.Should().Contain(a => a.Contains("gcr-cache"));
|
|
}
|
|
|
|
// ─── Harbor Adoption Check ────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Harbor_Check_DetectsExistingHarborDeployment()
|
|
{
|
|
// Arrange — A cluster already has Harbor installed via Helm.
|
|
// The adoption check should discover it, report version, and enumerate
|
|
// existing proxy cache projects.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
Mock<IAdoptionCheck> harborCheck = new();
|
|
harborCheck.Setup(c => c.ComponentName).Returns("harbor");
|
|
harborCheck.Setup(c => c.DisplayName).Returns("Harbor (Container Registry & Helm Charts)");
|
|
|
|
harborCheck
|
|
.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ComponentCheckResult(
|
|
"harbor",
|
|
ComponentStatus.Installed,
|
|
Details: new List<string>
|
|
{
|
|
"Harbor 2.12.0 detected (Helm release 'harbor' in namespace 'harbor')",
|
|
"Registry endpoint: registry.internal.corp.com",
|
|
"Proxy cache projects: dockerhub-cache, ghcr-cache, quay-cache",
|
|
"Trivy scanner enabled",
|
|
"ChartMuseum enabled"
|
|
},
|
|
MissingItems: new List<string>(),
|
|
Configuration: new DiscoveredConfiguration(
|
|
Version: "2.12.0",
|
|
Namespace: "harbor",
|
|
HelmReleaseName: "harbor",
|
|
Values: new Dictionary<string, string>
|
|
{
|
|
["registryEndpoint"] = "registry.internal.corp.com",
|
|
["proxyCacheProjects"] = "dockerhub-cache,ghcr-cache,quay-cache",
|
|
["trivyEnabled"] = "true",
|
|
["chartMuseumEnabled"] = "true",
|
|
["storageClass"] = "longhorn",
|
|
["storageSize"] = "100Gi"
|
|
})));
|
|
|
|
// Act
|
|
|
|
ComponentCheckResult checkResult = await harborCheck.Object.CheckAsync(cluster);
|
|
|
|
// Assert — Harbor detected with full configuration discovery.
|
|
|
|
checkResult.Status.Should().Be(ComponentStatus.Installed);
|
|
checkResult.Configuration.Should().NotBeNull();
|
|
checkResult.Configuration!.Version.Should().Be("2.12.0");
|
|
checkResult.Configuration.HelmReleaseName.Should().Be("harbor");
|
|
checkResult.Configuration.Values["proxyCacheProjects"].Should().Contain("dockerhub-cache");
|
|
checkResult.Details.Should().Contain(d => d.Contains("Trivy"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Harbor_Check_DetectsPartialInstallation()
|
|
{
|
|
// Arrange — Harbor is installed but some pods are not healthy (e.g.,
|
|
// the database pod is in CrashLoopBackOff). The check reports Degraded.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
Mock<IAdoptionCheck> harborCheck = new();
|
|
harborCheck.Setup(c => c.ComponentName).Returns("harbor");
|
|
harborCheck.Setup(c => c.DisplayName).Returns("Harbor (Container Registry & Helm Charts)");
|
|
|
|
harborCheck
|
|
.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ComponentCheckResult(
|
|
"harbor",
|
|
ComponentStatus.Degraded,
|
|
Details: new List<string>
|
|
{
|
|
"Harbor Helm release found in namespace 'harbor'",
|
|
"Core component: Running",
|
|
"Registry component: Running",
|
|
"Database component: CrashLoopBackOff"
|
|
},
|
|
MissingItems: new List<string>
|
|
{
|
|
"harbor-database pod unhealthy (CrashLoopBackOff)"
|
|
}));
|
|
|
|
// Act
|
|
|
|
ComponentCheckResult checkResult = await harborCheck.Object.CheckAsync(cluster);
|
|
|
|
// Assert — Degraded status with details about what's wrong.
|
|
|
|
checkResult.Status.Should().Be(ComponentStatus.Degraded);
|
|
checkResult.MissingItems.Should().Contain(m => m.Contains("database"));
|
|
checkResult.Details.Should().Contain(d => d.Contains("CrashLoopBackOff"));
|
|
}
|
|
|
|
private static KubernetesCluster CreateConnectedCluster()
|
|
{
|
|
KubernetesCluster cluster = KubernetesCluster.Register(
|
|
"prod-cluster", "https://k8s.example.com:6443",
|
|
"apiVersion: v1\nkind: Config\nclusters: []\nusers: []\ncontexts: []",
|
|
Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
|
|
cluster.MarkConnected();
|
|
return cluster;
|
|
}
|
|
|
|
// ─── CNPG Database Integration ────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void HarborSchema_ContainsCnpgClusterNameParameter()
|
|
{
|
|
// Harbor should be able to reference the shared CNPG cluster for its
|
|
// database instead of using the embedded PostgreSQL sub-chart.
|
|
|
|
ConfigurationSchema schema = ComponentSchemas.GetSchema("harbor");
|
|
|
|
schema.Parameters.Should().Contain(p =>
|
|
p.Key == "cnpgClusterName" && p.Type == ParameterType.PostgresCluster);
|
|
}
|
|
|
|
[Fact]
|
|
public void HarborSchema_ContainsCnpgNamespaceParameter()
|
|
{
|
|
// The CNPG cluster namespace is needed to resolve the primary service endpoint.
|
|
|
|
ConfigurationSchema schema = ComponentSchemas.GetSchema("harbor");
|
|
|
|
schema.Parameters.Should().Contain(p => p.Key == "cnpgNamespace");
|
|
}
|
|
|
|
// ─── Harbor Database Credentials ──────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Harbor_Install_WithCnpg_StoresCredentialsInVault()
|
|
{
|
|
// Arrange — When Harbor is installed with a CNPG cluster, the installer
|
|
// should store the generated database credentials in the vault at
|
|
// infrastructure/{clusterId}/harbor/database/* so they're auditable
|
|
// and recoverable.
|
|
|
|
KubernetesCluster cluster = CreateConnectedCluster();
|
|
await repository.AddAsync(cluster);
|
|
|
|
// Track vault calls made by the installer.
|
|
List<string> vaultPaths = new();
|
|
|
|
harborInstaller
|
|
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new InstallResult(
|
|
Success: true,
|
|
ComponentName: "harbor",
|
|
Message: "Harbor installed with external CNPG database",
|
|
Actions: new List<string>
|
|
{
|
|
"Created database 'harbor' on CNPG cluster 'shared-pg'",
|
|
"Stored database credentials in Kubernetes Secret and vault",
|
|
"Helm install harbor v1.16.0"
|
|
}));
|
|
|
|
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
|
|
{
|
|
harborInstaller.Object
|
|
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
|
|
|
|
DeployComponentRequest request = new("harbor",
|
|
Parameters: new Dictionary<string, string>
|
|
{
|
|
["harborDomain"] = "registry.dev.example.com",
|
|
["cnpgClusterName"] = "shared-pg",
|
|
["cnpgNamespace"] = "cnpg-system"
|
|
});
|
|
|
|
// Act
|
|
|
|
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
|
|
|
|
// Assert — The install result should mention both the K8s Secret and vault.
|
|
|
|
result.IsSuccess.Should().BeTrue();
|
|
result.Value!.Actions.Should().Contain(a => a.Contains("Stored database credentials"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CnpgCredentialsSecret_ContainsAllRequiredKeys()
|
|
{
|
|
// When Harbor uses an external CNPG database, a K8s Secret is created
|
|
// with the database connection details. This secret must contain all
|
|
// four keys that Harbor expects: host, database, username, password.
|
|
|
|
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
|
|
"harbor-cnpg-credentials", "harbor",
|
|
"shared-pg-rw.cnpg-system.svc.cluster.local",
|
|
"harbor", "harbor", "secret-password-123");
|
|
|
|
secret.StringData.Should().ContainKey("host");
|
|
secret.StringData.Should().ContainKey("database");
|
|
secret.StringData.Should().ContainKey("username");
|
|
secret.StringData.Should().ContainKey("password");
|
|
secret.StringData["host"].Should().Be("shared-pg-rw.cnpg-system.svc.cluster.local");
|
|
secret.StringData["database"].Should().Be("harbor");
|
|
secret.StringData["username"].Should().Be("harbor");
|
|
secret.StringData["password"].Should().Be("secret-password-123");
|
|
}
|
|
|
|
[Fact]
|
|
public void CnpgCredentialsSecret_HasManagementLabels()
|
|
{
|
|
// The credentials secret should be labeled for EntKube management
|
|
// so it can be discovered and cleaned up.
|
|
|
|
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
|
|
"harbor-cnpg-credentials", "harbor",
|
|
"host", "harbor", "harbor", "pass");
|
|
|
|
secret.Metadata.Labels["app.kubernetes.io/managed-by"].Should().Be("entkube");
|
|
secret.Metadata.Labels["entkube.io/purpose"].Should().Be("database-credentials");
|
|
}
|
|
}
|