Files
Entkube/src/EntKube.Clusters/Features/AdoptCluster/Components/Harbor/HarborCheck.cs
2026-05-13 14:01:32 +02:00

310 lines
9.8 KiB
C#

using System.Text;
using System.Text.Json;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.AdoptCluster.Components.Harbor;
/// <summary>
/// Checks whether Harbor container registry is installed on the cluster.
/// Harbor serves three purposes for the platform:
///
/// 1. **Private registry** — hosting container images and Helm charts
/// 2. **Pull-through cache** — caching upstream registries (Docker Hub, ghcr.io, quay.io)
/// 3. **Security scanning** — Trivy-based vulnerability scanning on all images
///
/// Discovery looks for the Harbor Helm release, verifies core components are healthy,
/// and enumerates proxy cache projects that are configured.
/// </summary>
public class HarborCheck : IAdoptionCheck
{
private readonly ILogger<HarborCheck> logger;
private const string DefaultNamespace = "harbor";
public string ComponentName => "harbor";
public string DisplayName => "Harbor (Container Registry & Helm Charts)";
public HarborCheck(ILogger<HarborCheck> logger)
{
this.logger = logger;
}
public async Task<ComponentCheckResult> CheckAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
Kubernetes client = BuildClient(cluster);
List<string> details = new();
List<string> missing = new();
// Check 1: Look for Harbor pods in the expected namespace.
// Harbor has several components: core, registry, portal, database, redis, trivy, jobservice.
Dictionary<string, string> componentHealth = await CheckHarborComponents(client, ct);
if (componentHealth.Count == 0)
{
missing.Add("No Harbor pods found in 'harbor' namespace");
return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing);
}
// Report the status of each component we found.
List<string> unhealthyComponents = new();
foreach (KeyValuePair<string, string> component in componentHealth)
{
details.Add($"{component.Key} component: {component.Value}");
if (component.Value != "Running")
{
unhealthyComponents.Add($"harbor-{component.Key} pod unhealthy ({component.Value})");
}
}
// Check 2: Detect the registry endpoint from the Ingress.
string? registryEndpoint = await DetectRegistryEndpoint(client, ct);
if (registryEndpoint is not null)
{
details.Add($"Registry endpoint: {registryEndpoint}");
}
// Check 3: Detect features (Trivy scanner, ChartMuseum).
bool trivyEnabled = componentHealth.ContainsKey("Trivy");
bool chartMuseumEnabled = componentHealth.ContainsKey("ChartMuseum");
if (trivyEnabled)
{
details.Add("Trivy scanner enabled");
}
if (chartMuseumEnabled)
{
details.Add("ChartMuseum enabled");
}
// Check 4: Detect Helm release version.
string? helmVersion = await DetectHelmReleaseVersion(client, ct);
if (helmVersion is not null)
{
details.Insert(0, $"Harbor {helmVersion} detected (Helm release 'harbor' in namespace '{DefaultNamespace}')");
}
// Build discovered configuration.
Dictionary<string, string> values = new()
{
["trivyEnabled"] = trivyEnabled.ToString(),
["chartMuseumEnabled"] = chartMuseumEnabled.ToString()
};
if (registryEndpoint is not null)
{
values["registryEndpoint"] = registryEndpoint;
}
DiscoveredConfiguration config = new(
Version: helmVersion,
Namespace: DefaultNamespace,
HelmReleaseName: "harbor",
Values: values);
// Determine overall status.
if (unhealthyComponents.Count > 0)
{
missing.AddRange(unhealthyComponents);
return new ComponentCheckResult(ComponentName, ComponentStatus.Degraded, details, missing, config);
}
return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config);
}
/// <summary>
/// Finds all Harbor-related pods and groups them by component, reporting the phase
/// of each. Harbor labels its pods with app.kubernetes.io/component.
/// </summary>
private async Task<Dictionary<string, string>> CheckHarborComponents(Kubernetes client, CancellationToken ct)
{
Dictionary<string, string> components = new();
try
{
V1PodList podList = await client.CoreV1.ListNamespacedPodAsync(
DefaultNamespace,
labelSelector: "app.kubernetes.io/name=harbor",
cancellationToken: ct);
foreach (V1Pod pod in podList.Items)
{
string? component = null;
pod.Metadata.Labels?.TryGetValue("app.kubernetes.io/component", out component);
if (component is null)
{
// Fallback: try to infer from pod name.
component = InferComponentFromPodName(pod.Metadata.Name);
}
if (component is not null)
{
string phase = pod.Status?.Phase ?? "Unknown";
// Use the worst status if multiple pods for the same component.
if (!components.ContainsKey(component) || phase != "Running")
{
components[component] = phase;
}
}
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
logger.LogDebug("Harbor namespace not found");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error checking for Harbor pods");
}
return components;
}
private static string? InferComponentFromPodName(string podName)
{
if (podName.Contains("core"))
{
return "Core";
}
if (podName.Contains("registry"))
{
return "Registry";
}
if (podName.Contains("portal") || podName.Contains("nginx"))
{
return "Portal";
}
if (podName.Contains("database") || podName.Contains("postgres"))
{
return "Database";
}
if (podName.Contains("redis"))
{
return "Redis";
}
if (podName.Contains("trivy"))
{
return "Trivy";
}
if (podName.Contains("jobservice"))
{
return "JobService";
}
if (podName.Contains("chartmuseum"))
{
return "ChartMuseum";
}
return null;
}
/// <summary>
/// Looks for an Ingress resource in the Harbor namespace to determine the registry endpoint.
/// </summary>
private async Task<string?> DetectRegistryEndpoint(Kubernetes client, CancellationToken ct)
{
try
{
V1IngressList ingresses = await client.NetworkingV1.ListNamespacedIngressAsync(
DefaultNamespace, cancellationToken: ct);
foreach (V1Ingress ingress in ingresses.Items)
{
if (ingress.Spec?.Rules is not null)
{
foreach (V1IngressRule rule in ingress.Spec.Rules)
{
if (rule.Host is not null)
{
return rule.Host;
}
}
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not detect Harbor ingress endpoint");
}
return null;
}
/// <summary>
/// Detects the Helm release version by looking at the Helm release Secret
/// stored in the namespace (Helm stores release metadata as Secrets with
/// label owner=helm and type=helm.sh/release.v1).
/// </summary>
private async Task<string?> DetectHelmReleaseVersion(Kubernetes client, CancellationToken ct)
{
try
{
V1SecretList secrets = await client.CoreV1.ListNamespacedSecretAsync(
DefaultNamespace,
labelSelector: "owner=helm,name=harbor",
cancellationToken: ct);
if (secrets.Items.Count > 0)
{
// Get the latest release (highest version number).
V1Secret? latestRelease = secrets.Items
.OrderByDescending(s =>
{
string? ver = null;
s.Metadata.Labels?.TryGetValue("version", out ver);
return ver ?? "0";
})
.FirstOrDefault();
if (latestRelease?.Metadata.Labels?.TryGetValue("version", out string? version) == true)
{
// The actual app version is embedded in the release data,
// but for a quick check we return the chart version from labels.
return version;
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not detect Harbor Helm release version");
}
return null;
}
private static Kubernetes BuildClient(KubernetesCluster cluster)
{
byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig);
using MemoryStream stream = new(kubeConfigBytes);
KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName);
return new Kubernetes(config);
}
}