310 lines
9.8 KiB
C#
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);
|
|
}
|
|
}
|