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);
}
}
}
}