using System.Text; using EntKube.Clusters.Domain; using k8s; using k8s.Models; using Microsoft.Extensions.Logging; namespace EntKube.Clusters.Features.AdoptCluster.Components.CloudNativePG; /// /// 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). /// public class CloudNativePGInstaller : IComponentInstaller { private readonly ILogger 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 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; bool monitoringEnabled = options.Parameters?.TryGetValue("monitoringEnabled", out string? monVal) == true && monVal == "true"; List 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); } } } /// /// Reconfigures CNPG operator. Supports: /// - "operatorReplicas": number of operator replicas /// - "monitoringEnabled": enable/disable Prometheus monitoring /// 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-cnpg-cfg-{Guid.NewGuid()}.kubeconfig"); try { await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); List 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 { $"Error: {ex.Message}" }); } finally { if (File.Exists(tempKubeConfig)) { File.Delete(tempKubeConfig); } } } /// /// Uninstalls CloudNativePG operator from the cluster. Warning: existing /// PostgreSQL Cluster resources will become unmanaged. /// 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-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 { $"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 { ["app.kubernetes.io/managed-by"] = "entkube", ["app.kubernetes.io/part-of"] = "cnpg-platform" } } }; 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); } /// /// 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 /// private async Task EnsureBackupStorageSecret( Kubernetes client, string namespaceName, StorageBucket bucket, CancellationToken ct) { Dictionary 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 { ["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); } } }