too much for one commit
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
using System.Text;
|
||||
using EntKube.Clusters.Domain;
|
||||
using k8s;
|
||||
using k8s.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace EntKube.Clusters.Features.AdoptCluster.Components.CloudNativePG;
|
||||
|
||||
/// <summary>
|
||||
/// Installs CloudNativePG (CNPG) operator via Helm. The operator manages the full
|
||||
/// lifecycle of PostgreSQL databases on Kubernetes — provisioning, HA failover,
|
||||
/// automated backups, point-in-time recovery, and rolling updates.
|
||||
///
|
||||
/// What gets deployed:
|
||||
/// 1. cnpg-system namespace with the CNPG operator controller
|
||||
/// 2. All required CRDs (Cluster, Backup, ScheduledBackup, Pooler, etc.)
|
||||
/// 3. Webhook configuration for validating/mutating PostgreSQL clusters
|
||||
///
|
||||
/// Configuration keys:
|
||||
/// - "operatorReplicas": Number of operator controller replicas (default: 1)
|
||||
/// - "monitoringEnabled": Whether to create ServiceMonitor for Prometheus scraping
|
||||
///
|
||||
/// Note: This installs the OPERATOR only. Actual PostgreSQL clusters are provisioned
|
||||
/// separately via the Provisioning service (per-tenant databases).
|
||||
/// </summary>
|
||||
public class CloudNativePGInstaller : IComponentInstaller
|
||||
{
|
||||
private readonly ILogger<CloudNativePGInstaller> logger;
|
||||
|
||||
private const string DefaultVersion = "0.22.0";
|
||||
private const string DefaultNamespace = "cnpg-system";
|
||||
private const string HelmRepo = "https://cloudnative-pg.github.io/charts";
|
||||
|
||||
public string ComponentName => "cnpg";
|
||||
|
||||
public CloudNativePGInstaller(ILogger<CloudNativePGInstaller> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
|
||||
{
|
||||
string version = options.Version ?? DefaultVersion;
|
||||
string targetNamespace = options.Namespace ?? DefaultNamespace;
|
||||
bool monitoringEnabled = options.Parameters?.TryGetValue("monitoringEnabled", out string? monVal) == true
|
||||
&& monVal == "true";
|
||||
List<string> actions = new();
|
||||
|
||||
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-cnpg-{Guid.NewGuid()}.kubeconfig");
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
|
||||
|
||||
// Ensure the namespace exists with management labels.
|
||||
|
||||
Kubernetes client = BuildClient(cluster);
|
||||
await EnsureNamespaceWithLabels(client, targetNamespace, ct);
|
||||
actions.Add($"Ensured namespace '{targetNamespace}' with management labels");
|
||||
|
||||
// Install CNPG operator via Helm. The chart includes all CRDs,
|
||||
// the controller deployment, webhook configuration, and RBAC.
|
||||
|
||||
string monitoringFlag = monitoringEnabled
|
||||
? "--set monitoring.podMonitorEnabled=true --set monitoring.grafanaDashboard.create=true"
|
||||
: "--set monitoring.podMonitorEnabled=false";
|
||||
|
||||
await RunCommand("helm",
|
||||
$"upgrade --install cnpg cloudnative-pg " +
|
||||
$"--repo {HelmRepo} --version {version} " +
|
||||
$"--namespace {targetNamespace} " +
|
||||
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
|
||||
$"--set replicaCount=1 " +
|
||||
$"--set resources.requests.cpu=100m " +
|
||||
$"--set resources.requests.memory=256Mi " +
|
||||
$"--set resources.limits.cpu=500m " +
|
||||
$"--set resources.limits.memory=512Mi " +
|
||||
$"{monitoringFlag} " +
|
||||
$"--wait --timeout 10m",
|
||||
ct);
|
||||
actions.Add($"Helm install cnpg operator v{version}");
|
||||
|
||||
// If the user selected a storage bucket for WAL/backup, create a
|
||||
// Kubernetes secret with the bucket credentials so CNPG clusters can
|
||||
// reference it for Barman object store (S3-based WAL archiving).
|
||||
|
||||
if (options.Parameters?.TryGetValue("walBucketId", out string? walBucketIdVal) == true
|
||||
&& !string.IsNullOrEmpty(walBucketIdVal) && Guid.TryParse(walBucketIdVal, out Guid walBucketId))
|
||||
{
|
||||
StorageBucket? walBucket = cluster.StorageBuckets.FirstOrDefault(b => b.Id == walBucketId);
|
||||
|
||||
if (walBucket is not null)
|
||||
{
|
||||
await EnsureBackupStorageSecret(client, targetNamespace, walBucket, ct);
|
||||
actions.Add($"Created backup storage secret 'cnpg-backup-creds' with bucket '{walBucket.Name}'");
|
||||
actions.Add($"CNPG clusters can use: endpointURL={walBucket.Endpoint}, bucket={walBucket.Name}, secret=cnpg-backup-creds");
|
||||
logger.LogInformation("Created CNPG backup storage secret for bucket '{BucketName}'", walBucket.Name);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("CloudNativePG Operator {Version} installed on cluster {Cluster}",
|
||||
version, cluster.Name);
|
||||
|
||||
return new InstallResult(
|
||||
Success: true,
|
||||
ComponentName: ComponentName,
|
||||
Message: $"CloudNativePG Operator {version} installed",
|
||||
Actions: actions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to install CNPG on cluster {Cluster}", cluster.Name);
|
||||
actions.Add($"Error: {ex.Message}");
|
||||
|
||||
return new InstallResult(
|
||||
Success: false,
|
||||
ComponentName: ComponentName,
|
||||
Message: $"Failed to install CloudNativePG: {ex.Message}",
|
||||
Actions: actions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempKubeConfig))
|
||||
{
|
||||
File.Delete(tempKubeConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconfigures CNPG operator. Supports:
|
||||
/// - "operatorReplicas": number of operator replicas
|
||||
/// - "monitoringEnabled": enable/disable Prometheus monitoring
|
||||
/// </summary>
|
||||
public async Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
|
||||
{
|
||||
string targetNamespace = configuration.Namespace ?? DefaultNamespace;
|
||||
List<string> actions = new();
|
||||
|
||||
string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-cnpg-cfg-{Guid.NewGuid()}.kubeconfig");
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
|
||||
|
||||
List<string> setFlags = new();
|
||||
|
||||
if (configuration.Values.TryGetValue("operatorReplicas", out string? replicas))
|
||||
{
|
||||
setFlags.Add($"--set replicaCount={replicas}");
|
||||
}
|
||||
|
||||
if (configuration.Values.TryGetValue("monitoringEnabled", out string? monitoring))
|
||||
{
|
||||
setFlags.Add($"--set monitoring.podMonitorEnabled={monitoring}");
|
||||
setFlags.Add($"--set monitoring.grafanaDashboard.create={monitoring}");
|
||||
}
|
||||
|
||||
await RunCommand("helm",
|
||||
$"upgrade cnpg cloudnative-pg --repo {HelmRepo} " +
|
||||
$"--namespace {targetNamespace} " +
|
||||
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
|
||||
$"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m",
|
||||
ct);
|
||||
|
||||
actions.Add($"Reconfigured CNPG: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}");
|
||||
|
||||
return new InstallResult(
|
||||
Success: true,
|
||||
ComponentName: ComponentName,
|
||||
Message: "CloudNativePG operator reconfigured",
|
||||
Actions: actions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to reconfigure CNPG on cluster {Cluster}", cluster.Name);
|
||||
return new InstallResult(
|
||||
Success: false,
|
||||
ComponentName: ComponentName,
|
||||
Message: $"Failed to reconfigure CloudNativePG: {ex.Message}",
|
||||
Actions: new List<string> { $"Error: {ex.Message}" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempKubeConfig))
|
||||
{
|
||||
File.Delete(tempKubeConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uninstalls CloudNativePG operator from the cluster. Warning: existing
|
||||
/// PostgreSQL Cluster resources will become unmanaged.
|
||||
/// </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-cnpg-rm-{Guid.NewGuid()}.kubeconfig");
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct);
|
||||
|
||||
await RunCommand("helm",
|
||||
$"uninstall cnpg --namespace {targetNamespace} " +
|
||||
$"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " +
|
||||
$"--wait --timeout 5m",
|
||||
ct);
|
||||
actions.Add("Helm uninstall cnpg");
|
||||
|
||||
logger.LogInformation("CloudNativePG uninstalled from cluster {Cluster}", cluster.Name);
|
||||
|
||||
return new InstallResult(
|
||||
Success: true,
|
||||
ComponentName: ComponentName,
|
||||
Message: "CloudNativePG operator uninstalled successfully",
|
||||
Actions: actions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to uninstall CloudNativePG from cluster {Cluster}", cluster.Name);
|
||||
return new InstallResult(
|
||||
Success: false,
|
||||
ComponentName: ComponentName,
|
||||
Message: $"Failed to uninstall CloudNativePG: {ex.Message}",
|
||||
Actions: new List<string> { $"Error: {ex.Message}" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempKubeConfig))
|
||||
{
|
||||
File.Delete(tempKubeConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureNamespaceWithLabels(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<string, string>
|
||||
{
|
||||
["app.kubernetes.io/managed-by"] = "entkube",
|
||||
["app.kubernetes.io/part-of"] = "cnpg-platform"
|
||||
}
|
||||
}
|
||||
};
|
||||
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>
|
||||
/// Creates or updates a Kubernetes Secret with S3 bucket credentials that
|
||||
/// CNPG clusters can reference for Barman object store (WAL archiving and
|
||||
/// base backups). The secret keys match what CNPG expects:
|
||||
/// - ACCESS_KEY_ID: S3 access key
|
||||
/// - ACCESS_SECRET_KEY: S3 secret key
|
||||
/// - BUCKET_NAME: target bucket name
|
||||
/// - ENDPOINT_URL: S3 endpoint URL
|
||||
/// </summary>
|
||||
private async Task EnsureBackupStorageSecret(
|
||||
Kubernetes client, string namespaceName, StorageBucket bucket, CancellationToken ct)
|
||||
{
|
||||
Dictionary<string, byte[]> secretData = new()
|
||||
{
|
||||
["ACCESS_KEY_ID"] = Encoding.UTF8.GetBytes(bucket.AccessKey),
|
||||
["ACCESS_SECRET_KEY"] = Encoding.UTF8.GetBytes(bucket.SecretKey),
|
||||
["BUCKET_NAME"] = Encoding.UTF8.GetBytes(bucket.Name),
|
||||
["ENDPOINT_URL"] = Encoding.UTF8.GetBytes(bucket.Endpoint)
|
||||
};
|
||||
|
||||
// Include the S3 region when available. External S3 providers like
|
||||
// Cleura require a region for AWS Signature V4 authentication in
|
||||
// barman-cloud. Without it, WAL archiving fails silently.
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bucket.Region))
|
||||
{
|
||||
secretData["AWS_DEFAULT_REGION"] = Encoding.UTF8.GetBytes(bucket.Region);
|
||||
}
|
||||
|
||||
V1Secret secret = new()
|
||||
{
|
||||
Metadata = new V1ObjectMeta
|
||||
{
|
||||
Name = "cnpg-backup-creds",
|
||||
NamespaceProperty = namespaceName,
|
||||
Labels = new Dictionary<string, string>
|
||||
{
|
||||
["app.kubernetes.io/managed-by"] = "entkube",
|
||||
["app.kubernetes.io/component"] = "cnpg-backup"
|
||||
}
|
||||
},
|
||||
Data = secretData
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await client.CoreV1.ReplaceNamespacedSecretAsync(secret, "cnpg-backup-creds", namespaceName, cancellationToken: ct);
|
||||
}
|
||||
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
await client.CoreV1.CreateNamespacedSecretAsync(secret, namespaceName, cancellationToken: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user