using System.Text; using EntKube.Clusters.Domain; using k8s; using k8s.Models; using Microsoft.Extensions.Logging; namespace EntKube.Clusters.Features.AdoptCluster.Components.CertManager; /// /// Installs cert-manager via Helm. cert-manager provides the CRD runtime for /// Certificate, Issuer, and ClusterIssuer resources. Let's Encrypt ClusterIssuer /// configuration is handled separately by the LetsEncryptInstaller. /// /// What gets deployed: /// 1. cert-manager namespace with controller, webhook, and cainjector /// 2. CRDs for Certificate, Issuer, ClusterIssuer, etc. /// /// Configuration keys: /// - "replicas": cert-manager controller replicas (default: 1) /// public class CertManagerInstaller : IComponentInstaller { private readonly ILogger logger; private const string DefaultVersion = "1.16.3"; private const string DefaultNamespace = "cert-manager"; private const string HelmRepo = "https://charts.jetstack.io"; public string ComponentName => "cert-manager"; public CertManagerInstaller(ILogger logger) { this.logger = logger; } public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) { string version = options.Version ?? DefaultVersion; string targetNamespace = options.Namespace ?? DefaultNamespace; List actions = new(); string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-certmgr-{Guid.NewGuid()}.kubeconfig"); try { await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); // Ensure the namespace exists. Kubernetes client = BuildClient(cluster); await EnsureNamespace(client, targetNamespace, ct); actions.Add($"Ensured namespace '{targetNamespace}'"); // Install cert-manager with CRDs via Helm. await RunCommand("helm", $"upgrade --install cert-manager cert-manager " + $"--repo {HelmRepo} --version v{version} " + $"--namespace {targetNamespace} " + $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + $"--set crds.enabled=true " + $"--set replicaCount=1 " + $"--set webhook.replicaCount=1 " + $"--set cainjector.replicaCount=1 " + $"--set resources.requests.cpu=50m " + $"--set resources.requests.memory=128Mi " + $"--set resources.limits.cpu=200m " + $"--set resources.limits.memory=256Mi " + $"--set prometheus.enabled=true " + $"--set prometheus.servicemonitor.enabled=false " + $"--wait --timeout 10m", ct); actions.Add($"Helm install cert-manager v{version} with CRDs"); logger.LogInformation("cert-manager {Version} installed on cluster {Cluster}", version, cluster.Name); return new InstallResult( Success: true, ComponentName: ComponentName, Message: $"cert-manager {version} installed", Actions: actions); } catch (Exception ex) { logger.LogError(ex, "Failed to install cert-manager on cluster {Cluster}", cluster.Name); actions.Add($"Error: {ex.Message}"); return new InstallResult( Success: false, ComponentName: ComponentName, Message: $"Failed to install cert-manager: {ex.Message}", Actions: actions); } finally { if (File.Exists(tempKubeConfig)) { File.Delete(tempKubeConfig); } } } /// /// Reconfigures cert-manager. Supports: /// - "replicas": controller replicas /// public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) { string targetNamespace = configuration.Namespace ?? DefaultNamespace; List actions = new(); string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-certmgr-cfg-{Guid.NewGuid()}.kubeconfig"); try { await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); // Handle Helm-level configuration (replicas, resources). List setFlags = new(); if (configuration.Values.TryGetValue("replicas", out string? replicas)) { setFlags.Add($"--set replicaCount={replicas}"); } if (setFlags.Count > 0) { await RunCommand("helm", $"upgrade cert-manager cert-manager --repo {HelmRepo} " + $"--namespace {targetNamespace} " + $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + $"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m", ct); actions.Add($"Updated cert-manager Helm release: {string.Join(", ", setFlags)}"); } return new InstallResult( Success: true, ComponentName: ComponentName, Message: "cert-manager reconfigured", Actions: actions); } catch (Exception ex) { logger.LogError(ex, "Failed to reconfigure cert-manager on cluster {Cluster}", cluster.Name); return new InstallResult( Success: false, ComponentName: ComponentName, Message: $"Failed to reconfigure cert-manager: {ex.Message}", Actions: new List { $"Error: {ex.Message}" }); } finally { if (File.Exists(tempKubeConfig)) { File.Delete(tempKubeConfig); } } } /// /// Uninstalls cert-manager from the cluster by removing the Helm release. /// Warning: this removes all CRDs too — any Certificate, Issuer, and /// ClusterIssuer resources on the cluster will be deleted. /// public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) { string targetNamespace = options.Namespace ?? DefaultNamespace; List actions = new(); string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-certmgr-rm-{Guid.NewGuid()}.kubeconfig"); try { await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); await RunCommand("helm", $"uninstall cert-manager --namespace {targetNamespace} " + $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + $"--wait --timeout 5m", ct); actions.Add("Helm uninstall cert-manager"); logger.LogInformation("cert-manager uninstalled from cluster {Cluster}", cluster.Name); return new InstallResult( Success: true, ComponentName: ComponentName, Message: "cert-manager uninstalled successfully", Actions: actions); } catch (Exception ex) { logger.LogError(ex, "Failed to uninstall cert-manager from cluster {Cluster}", cluster.Name); return new InstallResult( Success: false, ComponentName: ComponentName, Message: $"Failed to uninstall cert-manager: {ex.Message}", Actions: new List { $"Error: {ex.Message}" }); } finally { if (File.Exists(tempKubeConfig)) { File.Delete(tempKubeConfig); } } } private async Task EnsureNamespace(Kubernetes client, string namespaceName, CancellationToken ct) { try { await client.CoreV1.ReadNamespaceAsync(namespaceName, cancellationToken: ct); } catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) { V1Namespace ns = new() { Metadata = new V1ObjectMeta { Name = namespaceName, Labels = new Dictionary { ["app.kubernetes.io/managed-by"] = "entkube" } } }; await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); } } private async Task RunCommand(string fileName, string arguments, CancellationToken ct) { System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); if (process is null) { throw new InvalidOperationException($"Failed to start {fileName}"); } string output = await process.StandardOutput.ReadToEndAsync(ct); string error = await process.StandardError.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); if (process.ExitCode != 0) { throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); } return output; } 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); } }