using System.Text; using EntKube.Clusters.Domain; using k8s; using Microsoft.Extensions.Logging; namespace EntKube.Clusters.Features.AdoptCluster.Components.Istio; /// /// Installs Istio with Gateway API support on a cluster. Mirrors the Terraform /// bootstrap modules: gateway_api → istio_base → istio_control_plane (istiod). /// /// Deploys in order: /// 1. Gateway API CRDs (standard-install.yaml from upstream) /// 2. istio-base Helm chart (Istio CRDs) /// 3. istiod Helm chart (control plane with Gateway API enabled) /// /// Configuration matches the Terraform: /// - PILOT_ENABLE_GATEWAY_API=true /// - PILOT_ENABLE_GATEWAY_API_STATUS=true /// - accessLogFile=/dev/stdout, JSON encoding /// - enablePrometheusMerge=true /// public class IstioInstaller : IComponentInstaller { private readonly ILogger logger; private const string DefaultIstioVersion = "1.24.0"; private const string DefaultGatewayApiVersion = "1.2.0"; private const string DefaultNamespace = "istio-system"; private const string HelmRepo = "https://istio-release.storage.googleapis.com/charts"; public string ComponentName => "istio"; public IstioInstaller(ILogger logger) { this.logger = logger; } public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) { string istioVersion = options.Version ?? DefaultIstioVersion; string gatewayApiVersion = options.Parameters?.GetValueOrDefault("gatewayApiVersion") ?? DefaultGatewayApiVersion; string targetNamespace = options.Namespace ?? DefaultNamespace; List actions = new(); string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-istio-{Guid.NewGuid()}.kubeconfig"); try { await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); // Step 1: Install Gateway API CRDs (must come before Istio) string gatewayApiResult = await RunCommand("kubectl", $"apply --server-side --force-conflicts --kubeconfig \"{tempKubeConfig}\" --context \"{cluster.ContextName}\" " + $"-f https://github.com/kubernetes-sigs/gateway-api/releases/download/v{gatewayApiVersion}/standard-install.yaml", ct); actions.Add($"Applied Gateway API CRDs v{gatewayApiVersion}"); // Step 2: Create istio-system namespace Kubernetes client = BuildClient(cluster); await EnsureNamespaceExists(client, targetNamespace, ct); actions.Add($"Ensured namespace '{targetNamespace}' exists"); // Step 3: Install istio-base (CRDs). The --force-conflicts flag is needed // because the istiod-default-validator ValidatingWebhookConfiguration is // created by istio-base but its failurePolicy field gets adopted by the // running pilot-discovery controller. Without --force-conflicts, Helm's // server-side apply refuses to overwrite the field owned by pilot-discovery. await RunCommand("helm", $"upgrade --install istio-base base --repo {HelmRepo} --version {istioVersion} " + $"--namespace {targetNamespace} --kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + $"--force-conflicts " + $"--wait --timeout 5m", ct); actions.Add($"Helm install istio-base v{istioVersion}"); // Step 4: Install istiod with Gateway API enabled. Same --force-conflicts // needed here because istiod also manages webhook and CRD fields that // overlap with the running pilot-discovery controller's field ownership. await RunCommand("helm", $"upgrade --install istiod istiod --repo {HelmRepo} --version {istioVersion} " + $"--namespace {targetNamespace} --kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + $"--force-conflicts " + $"--set pilot.env.PILOT_ENABLE_GATEWAY_API=true " + $"--set pilot.env.PILOT_ENABLE_GATEWAY_API_STATUS=true " + $"--set pilot.env.PILOT_ENABLE_GATEWAY_API_DEPLOYMENT_CONTROLLER=false " + $"--set meshConfig.accessLogFile=/dev/stdout " + $"--set meshConfig.accessLogEncoding=JSON " + $"--set meshConfig.enablePrometheusMerge=true " + $"--wait --timeout 10m", ct); actions.Add($"Helm install istiod v{istioVersion} (Gateway API enabled)"); logger.LogInformation("Istio {Version} with Gateway API installed on cluster {Cluster}", istioVersion, cluster.Name); return new InstallResult( Success: true, ComponentName: ComponentName, Message: $"Istio {istioVersion} with Gateway API v{gatewayApiVersion} installed", Actions: actions); } catch (Exception ex) { logger.LogError(ex, "Failed to install Istio on cluster {Cluster}", cluster.Name); actions.Add($"Error: {ex.Message}"); return new InstallResult( Success: false, ComponentName: ComponentName, Message: $"Failed to install Istio: {ex.Message}", Actions: actions); } finally { if (File.Exists(tempKubeConfig)) { File.Delete(tempKubeConfig); } } } private async Task EnsureNamespaceExists(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) { k8s.Models.V1Namespace ns = new() { Metadata = new k8s.Models.V1ObjectMeta { Name = namespaceName } }; 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); } /// /// Reconfigures Istio. Supports: /// - "pilotReplicas": number of istiod replicas /// - "enableGatewayApi": true/false for PILOT_ENABLE_GATEWAY_API /// public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) { string targetNamespace = configuration.Namespace ?? "istio-system"; List actions = new(); string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-istio-cfg-{Guid.NewGuid()}.kubeconfig"); try { await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); List setFlags = new(); if (configuration.Values.TryGetValue("pilotReplicas", out string? replicas)) { setFlags.Add($"--set pilot.autoscaleMin={replicas}"); } if (configuration.Values.TryGetValue("enableGatewayApi", out string? gwApi)) { setFlags.Add($"--set pilot.env.PILOT_ENABLE_GATEWAY_API={gwApi}"); } await RunCommand("helm", $"upgrade istiod istiod --repo {HelmRepo} " + $"--namespace {targetNamespace} " + $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + $"--force-conflicts " + $"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m", ct); actions.Add($"Reconfigured Istio: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}"); return new InstallResult( Success: true, ComponentName: ComponentName, Message: "Istio reconfigured", Actions: actions); } catch (Exception ex) { logger.LogError(ex, "Failed to reconfigure Istio on cluster {Cluster}", cluster.Name); return new InstallResult( Success: false, ComponentName: ComponentName, Message: $"Failed to reconfigure Istio: {ex.Message}", Actions: new List { $"Error: {ex.Message}" }); } finally { if (File.Exists(tempKubeConfig)) { File.Delete(tempKubeConfig); } } } /// /// Uninstalls Istio from the cluster by removing istiod and istio-base /// Helm releases in reverse order. /// 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-istio-rm-{Guid.NewGuid()}.kubeconfig"); try { await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); // Uninstall in reverse order: istiod first, then istio-base. await RunCommand("helm", $"uninstall istiod --namespace {targetNamespace} " + $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + $"--wait --timeout 5m", ct); actions.Add("Helm uninstall istiod"); await RunCommand("helm", $"uninstall istio-base --namespace {targetNamespace} " + $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + $"--wait --timeout 5m", ct); actions.Add("Helm uninstall istio-base"); logger.LogInformation("Istio uninstalled from cluster {Cluster}", cluster.Name); return new InstallResult( Success: true, ComponentName: ComponentName, Message: "Istio uninstalled successfully", Actions: actions); } catch (Exception ex) { logger.LogError(ex, "Failed to uninstall Istio from cluster {Cluster}", cluster.Name); return new InstallResult( Success: false, ComponentName: ComponentName, Message: $"Failed to uninstall Istio: {ex.Message}", Actions: actions.Concat(new[] { $"Error: {ex.Message}" }).ToList()); } finally { if (File.Exists(tempKubeConfig)) { File.Delete(tempKubeConfig); } } } }