298 lines
12 KiB
C#
298 lines
12 KiB
C#
using System.Text;
|
|
using EntKube.Clusters.Domain;
|
|
using k8s;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace EntKube.Clusters.Features.AdoptCluster.Components.Istio;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
public class IstioInstaller : IComponentInstaller
|
|
{
|
|
private readonly ILogger<IstioInstaller> 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<IstioInstaller> logger)
|
|
{
|
|
this.logger = logger;
|
|
}
|
|
|
|
public async Task<InstallResult> 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<string> 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<string> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reconfigures Istio. Supports:
|
|
/// - "pilotReplicas": number of istiod replicas
|
|
/// - "enableGatewayApi": true/false for PILOT_ENABLE_GATEWAY_API
|
|
/// </summary>
|
|
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
|
|
{
|
|
string targetNamespace = configuration.Namespace ?? "istio-system";
|
|
List<string> actions = new();
|
|
|
|
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-istio-cfg-{Guid.NewGuid()}.kubeconfig");
|
|
|
|
try
|
|
{
|
|
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
|
|
|
|
List<string> 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<string> { $"Error: {ex.Message}" });
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(tempKubeConfig))
|
|
{
|
|
File.Delete(tempKubeConfig);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Uninstalls Istio from the cluster by removing istiod and istio-base
|
|
/// Helm releases in reverse order.
|
|
/// </summary>
|
|
public async Task<InstallResult> UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
|
|
{
|
|
string targetNamespace = options.Namespace ?? DefaultNamespace;
|
|
List<string> 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);
|
|
}
|
|
}
|
|
}
|
|
}
|