Files
Entkube/src/EntKube.Web/Services/MongoService.cs
2026-05-21 16:57:50 +02:00

1890 lines
86 KiB
C#

using System.Security.Cryptography;
using System.Text;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Services;
/// <summary>
/// Manages the full lifecycle of MongoDB Community Operator clusters — from creation
/// through backup, restore, upgrade, database management, and deletion. Each operation
/// translates high-level intent into Kubernetes CRD manifests or Jobs applied to the
/// cluster where the MongoDB Community Operator is running.
///
/// The service owns the MongoCluster, MongoDatabase, and MongoBackup records in the
/// database. It coordinates with VaultService to store database credentials and tag
/// them for Kubernetes sync so applications can consume them as K8s Secrets.
///
/// Backups are implemented as Kubernetes Jobs: an init container runs mongodump and
/// writes to an emptyDir volume, then the main container uploads the archive to S3
/// using the aws-cli image. Restores reverse this process. No PITR — recovery is to
/// the point in time of the selected backup dump.
/// </summary>
public class MongoService(
IDbContextFactory<ApplicationDbContext> dbFactory,
VaultService vaultService,
IKubernetesClientFactory k8sFactory)
{
// ──────── Cluster Lifecycle ────────
/// <summary>
/// Creates a new managed MongoDB cluster via the Community Operator. Validates that
/// the operator is installed on the target cluster, then generates the MongoDBCommunity
/// CRD manifest and applies it. If a storage link is provided, syncs S3 credentials
/// to a K8s Secret so backup Jobs can access the bucket.
/// </summary>
public async Task<MongoCluster> CreateClusterAsync(
Guid tenantId,
Guid kubernetesClusterId,
string name,
string ns,
int members,
string storageSize,
Guid? storageLinkId,
string? backupSchedule,
int retentionDays = 30,
CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
// Verify the MongoDB Community Operator is installed on the target cluster.
bool operatorInstalled = await db.ClusterComponents
.AnyAsync(c => c.ClusterId == kubernetesClusterId
&& (c.Name == "mongodb-community-operator" || c.Name == "mongodb-operator"
|| c.ReleaseName == "mongodb-community-operator" || c.ReleaseName == "mongodb-operator")
&& c.Status == ComponentStatus.Installed, ct);
if (!operatorInstalled)
{
throw new InvalidOperationException(
"MongoDB Community Operator is not installed on this cluster. Install it first from the Components tab.");
}
// Load the cluster's kubeconfig for applying the manifest.
KubernetesCluster k8sCluster = await db.KubernetesClusters
.FirstAsync(k => k.Id == kubernetesClusterId, ct);
// Load backup storage details if configured.
StorageLink? storageLink = null;
if (storageLinkId.HasValue)
{
storageLink = await db.StorageLinks
.FirstOrDefaultAsync(s => s.Id == storageLinkId.Value && s.TenantId == tenantId, ct);
}
// Create the database record.
MongoCluster mongoCluster = new()
{
Id = Guid.NewGuid(),
TenantId = tenantId,
KubernetesClusterId = kubernetesClusterId,
Name = name,
Namespace = ns,
MongoVersion = "8.0.4",
Members = members,
StorageSize = storageSize,
StorageLinkId = storageLinkId,
BackupSchedule = backupSchedule,
RetentionDays = retentionDays,
Status = MongoClusterStatus.Creating
};
db.MongoClusters.Add(mongoCluster);
await db.SaveChangesAsync(ct);
// If backup storage is configured, ensure the S3 credentials are synced
// to Kubernetes so backup Jobs can access the bucket.
string? s3SecretName = null;
if (storageLink is not null)
{
s3SecretName = $"{name}-s3-credentials";
}
// Generate an admin password. Store it in the vault (encrypted, linked to this
// cluster, with K8s sync enabled) and also apply the K8s Secret immediately so
// the Community Operator can find it before it processes the MongoDBCommunity CRD.
string adminPassword = GeneratePassword();
string adminK8sSecretName = $"{name}-admin-password";
await vaultService.InitializeVaultAsync(tenantId, ct);
await vaultService.SetMongoClusterSecretAsync(
tenantId, mongoCluster.Id, "ADMIN_PASSWORD", adminPassword,
adminK8sSecretName, ns, ct);
string adminSecretManifest = BuildAdminSecretManifest(name, ns, adminPassword);
string clusterManifest = BuildClusterManifest(mongoCluster, storageLink, s3SecretName);
await k8sFactory.EnsureNamespaceAsync(ns, k8sCluster.Kubeconfig!, ct);
// The Community Operator sets serviceAccountName: mongodb-database on every pod it creates.
// It only creates this ServiceAccount in its own installation namespace, so when the MongoDB
// resource lives in a different namespace the pod admission fails with "serviceaccount not found".
// We apply the ServiceAccount ourselves so it always exists in the target namespace.
await k8sFactory.ApplyManifestAsync(BuildServiceAccountManifest(ns), k8sCluster.Kubeconfig!, ct);
if (storageLink is not null && s3SecretName is not null)
{
await EnsureStorageSecretsInK8sAsync(
tenantId, storageLink, s3SecretName, ns, k8sCluster.Kubeconfig!, ct);
}
await k8sFactory.ApplyManifestAsync(adminSecretManifest, k8sCluster.Kubeconfig!, ct);
await k8sFactory.ApplyManifestAsync(clusterManifest, k8sCluster.Kubeconfig!, ct);
if (!string.IsNullOrEmpty(backupSchedule) && storageLink is not null && s3SecretName is not null)
{
string cronManifest = BuildScheduledBackupCronJobManifest(mongoCluster, storageLink, s3SecretName);
await k8sFactory.ApplyManifestAsync(cronManifest, k8sCluster.Kubeconfig!, ct);
}
return mongoCluster;
}
/// <summary>
/// Deletes a managed MongoDB cluster from Kubernetes and removes all associated
/// records (databases, backups, vault secrets).
/// </summary>
public async Task DeleteClusterAsync(Guid tenantId, Guid mongoClusterId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoCluster mongo = await db.MongoClusters
.Include(c => c.KubernetesCluster)
.Include(c => c.Databases)
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct)
?? throw new InvalidOperationException("MongoDB cluster not found.");
// Attempt to delete the K8s resources. If the cluster never actually deployed
// or the K8s API is unreachable, we still proceed with removing the DB record.
try
{
await k8sFactory.DeleteManifestAsync(
"mongodbcommunity.mongodbcommunity.mongodb.com", mongo.Name, mongo.Namespace,
mongo.KubernetesCluster.Kubeconfig!, ct);
await k8sFactory.DeleteManifestAsync(
"secret", $"{mongo.Name}-admin-password", mongo.Namespace,
mongo.KubernetesCluster.Kubeconfig!, ct);
if (!string.IsNullOrEmpty(mongo.BackupSchedule))
{
await k8sFactory.DeleteManifestAsync(
"cronjob", $"{mongo.Name}-scheduled-backup", mongo.Namespace,
mongo.KubernetesCluster.Kubeconfig!, ct);
}
}
catch (Exception)
{
// K8s operations failed — proceed with local cleanup regardless.
}
// Remove vault secrets for all databases in this cluster.
foreach (MongoDatabase database in mongo.Databases)
{
await DeleteDatabaseSecretsAsync(database.Id, ct);
}
// Remove cluster-level vault secrets (admin password, etc.).
await DeleteClusterSecretsAsync(mongo.Id, ct);
// Remove the cluster record (cascades to databases and backups).
db.MongoClusters.Remove(mongo);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Upgrades a MongoDB cluster to a new version. Updates spec.version in the CRD —
/// the Community Operator performs a rolling upgrade of replica set members.
/// </summary>
public async Task UpgradeClusterAsync(
Guid tenantId, Guid mongoClusterId, string targetVersion, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoCluster mongo = await db.MongoClusters
.Include(c => c.KubernetesCluster)
.Include(c => c.StorageLink)
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct)
?? throw new InvalidOperationException("MongoDB cluster not found.");
mongo.MongoVersion = targetVersion;
mongo.Status = MongoClusterStatus.Upgrading;
await db.SaveChangesAsync(ct);
string? s3SecretName = mongo.StorageLinkId.HasValue ? $"{mongo.Name}-s3-credentials" : null;
string manifest = BuildClusterManifest(mongo, mongo.StorageLink, s3SecretName);
await k8sFactory.ApplyManifestAsync(manifest, mongo.KubernetesCluster.Kubeconfig!, ct);
// Re-apply the CronJob so the mongodump image version stays in sync.
if (!string.IsNullOrEmpty(mongo.BackupSchedule) && mongo.StorageLink is not null && s3SecretName is not null)
{
string cronManifest = BuildScheduledBackupCronJobManifest(mongo, mongo.StorageLink, s3SecretName);
await k8sFactory.ApplyManifestAsync(cronManifest, mongo.KubernetesCluster.Kubeconfig!, ct);
}
}
// ──────── Backup & Restore ────────
/// <summary>
/// Triggers an on-demand backup by applying a Kubernetes Job that runs mongodump
/// and uploads the gzip archive to the configured S3 bucket. The backup archive
/// is stored at {prefix}/{backupName}.archive in the bucket.
/// </summary>
public async Task<MongoBackup> BackupAsync(Guid tenantId, Guid mongoClusterId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoCluster mongo = await db.MongoClusters
.Include(c => c.KubernetesCluster)
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct)
?? throw new InvalidOperationException("MongoDB cluster not found.");
if (!mongo.StorageLinkId.HasValue)
{
throw new InvalidOperationException(
"Backup storage is not configured for this cluster. Assign an S3 bucket first.");
}
// Generate a unique backup name with timestamp.
string backupName = $"{mongo.Name}-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
MongoBackup backup = new()
{
Id = Guid.NewGuid(),
MongoClusterId = mongo.Id,
Name = backupName,
Type = MongoBackupType.OnDemand,
Status = MongoBackupStatus.Running
};
db.MongoBackups.Add(backup);
await db.SaveChangesAsync(ct);
// Apply a Job that runs mongodump → S3 upload.
string s3SecretName = $"{mongo.Name}-s3-credentials";
StorageLink storageLink = await db.StorageLinks.FirstAsync(s => s.Id == mongo.StorageLinkId!.Value, ct);
await EnsureStorageSecretsInK8sAsync(
tenantId, storageLink, s3SecretName, mongo.Namespace, mongo.KubernetesCluster.Kubeconfig!, ct);
string manifest = BuildBackupManifest(backupName, mongo, storageLink, s3SecretName);
await k8sFactory.ApplyManifestAsync(manifest, mongo.KubernetesCluster.Kubeconfig!, ct);
return backup;
}
/// <summary>
/// Restores a MongoDB cluster from a previously taken backup. Creates a Kubernetes
/// Job that downloads the backup archive from S3 and runs mongorestore --drop against
/// the replica set. The sourceBackupId must refer to a completed OnDemand or Scheduled
/// backup whose archive exists in the configured S3 bucket.
/// </summary>
public async Task<MongoBackup> RestoreAsync(
Guid tenantId, Guid mongoClusterId, Guid sourceBackupId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoCluster mongo = await db.MongoClusters
.Include(c => c.KubernetesCluster)
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct)
?? throw new InvalidOperationException("MongoDB cluster not found.");
if (!mongo.StorageLinkId.HasValue)
{
throw new InvalidOperationException(
"Restore requires backup storage configured on this cluster.");
}
MongoBackup sourceBackup = await db.MongoBackups
.FirstOrDefaultAsync(b => b.Id == sourceBackupId && b.MongoClusterId == mongo.Id, ct)
?? throw new InvalidOperationException("Backup record not found.");
string restoreName = $"{mongo.Name}-restore-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
MongoBackup restoreRecord = new()
{
Id = Guid.NewGuid(),
MongoClusterId = mongo.Id,
Name = restoreName,
Type = MongoBackupType.OnDemand,
Status = MongoBackupStatus.Running
};
db.MongoBackups.Add(restoreRecord);
mongo.Status = MongoClusterStatus.Restoring;
await db.SaveChangesAsync(ct);
string s3SecretName = $"{mongo.Name}-s3-credentials";
StorageLink storageLink = await db.StorageLinks.FirstAsync(s => s.Id == mongo.StorageLinkId!.Value, ct);
await EnsureStorageSecretsInK8sAsync(
tenantId, storageLink, s3SecretName, mongo.Namespace, mongo.KubernetesCluster.Kubeconfig!, ct);
string manifest = BuildRestoreManifest(restoreName, mongo, storageLink, sourceBackup.Name, s3SecretName);
await k8sFactory.ApplyManifestAsync(manifest, mongo.KubernetesCluster.Kubeconfig!, ct);
return restoreRecord;
}
/// <summary>
/// Creates a new MongoDB cluster from an existing backup. The new cluster is
/// provisioned fresh (new admin credentials, RBAC, StatefulSet), then a restore
/// Job is applied that downloads the backup archive from S3 and runs mongorestore
/// into the new cluster's replica set.
/// </summary>
public async Task<MongoCluster> RestoreToNewClusterAsync(
Guid tenantId, Guid sourceMongoClusterId, Guid sourceBackupId, string newName,
CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoCluster source = await db.MongoClusters
.Include(c => c.KubernetesCluster)
.Include(c => c.StorageLink)
.FirstOrDefaultAsync(c => c.Id == sourceMongoClusterId && c.TenantId == tenantId, ct)
?? throw new InvalidOperationException("Source MongoDB cluster not found.");
if (!source.StorageLinkId.HasValue)
{
throw new InvalidOperationException(
"Restore requires backup storage configured on the source cluster.");
}
MongoBackup sourceBackup = await db.MongoBackups
.FirstOrDefaultAsync(b => b.Id == sourceBackupId && b.MongoClusterId == source.Id, ct)
?? throw new InvalidOperationException("Backup record not found.");
StorageLink storageLink = await db.StorageLinks
.FirstAsync(s => s.Id == source.StorageLinkId!.Value, ct);
MongoCluster newCluster = await CreateClusterAsync(
tenantId, source.KubernetesClusterId, newName, source.Namespace,
source.Members, source.StorageSize, source.StorageLinkId,
source.BackupSchedule, source.RetentionDays, ct);
string restoreName = $"{newName}-restore-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
string s3SecretName = $"{newName}-s3-credentials";
await EnsureStorageSecretsInK8sAsync(
tenantId, storageLink, s3SecretName, newCluster.Namespace,
source.KubernetesCluster.Kubeconfig!, ct);
// Cross-cluster restore: download the backup from the source cluster's S3 path,
// restore into the new cluster's service using the new cluster's admin credentials.
string manifest = BuildCrossClusterRestoreManifest(
restoreName, newCluster, source.Name, storageLink, sourceBackup.Name, s3SecretName);
await k8sFactory.ApplyManifestAsync(manifest, source.KubernetesCluster.Kubeconfig!, ct);
return newCluster;
}
/// <summary>
/// Restores a MongoDB backup to an external MongoDB server via a connection URI.
/// Creates a Kubernetes Job that downloads the archive from S3 and runs mongorestore
/// against the provided URI — useful for migrating data to an on-premise or cloud-
/// hosted MongoDB instance without creating a new Community Operator cluster.
/// </summary>
public async Task RestoreToExternalAsync(
Guid tenantId, Guid mongoClusterId, Guid sourceBackupId, string externalMongoUri,
CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoCluster mongo = await db.MongoClusters
.Include(c => c.KubernetesCluster)
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct)
?? throw new InvalidOperationException("MongoDB cluster not found.");
if (!mongo.StorageLinkId.HasValue)
{
throw new InvalidOperationException(
"Restore requires backup storage configured on this cluster.");
}
MongoBackup sourceBackup = await db.MongoBackups
.FirstOrDefaultAsync(b => b.Id == sourceBackupId && b.MongoClusterId == mongo.Id, ct)
?? throw new InvalidOperationException("Backup record not found.");
StorageLink storageLink = await db.StorageLinks
.FirstAsync(s => s.Id == mongo.StorageLinkId!.Value, ct);
string restoreName = $"{mongo.Name}-ext-restore-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
string s3SecretName = $"{mongo.Name}-s3-credentials";
await EnsureStorageSecretsInK8sAsync(
tenantId, storageLink, s3SecretName, mongo.Namespace,
mongo.KubernetesCluster.Kubeconfig!, ct);
string manifest = BuildExternalRestoreManifest(
restoreName, mongo, storageLink, sourceBackup.Name, s3SecretName, externalMongoUri);
await k8sFactory.ApplyManifestAsync(manifest, mongo.KubernetesCluster.Kubeconfig!, ct);
}
// ──────── Database Management ────────
/// <summary>
/// Creates a new database and user within a running MongoDB cluster.
/// Generates a random password, runs createUser via mongosh on the primary pod,
/// then stores the connection credentials in the vault tagged for Kubernetes sync.
/// </summary>
public async Task<MongoDatabase> CreateDatabaseAsync(
Guid tenantId, Guid mongoClusterId, string databaseName, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoCluster mongo = await db.MongoClusters
.Include(c => c.KubernetesCluster)
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct)
?? throw new InvalidOperationException("MongoDB cluster not found.");
string owner = $"{databaseName}_owner";
string password = GeneratePassword();
// Create the database record.
MongoDatabase database = new()
{
Id = Guid.NewGuid(),
MongoClusterId = mongo.Id,
Name = databaseName,
Owner = owner,
Status = MongoDatabaseStatus.Creating
};
db.MongoDatabases.Add(database);
await db.SaveChangesAsync(ct);
// Read the cluster admin password from the vault.
string? adminPassword = await vaultService.GetMongoClusterSecretValueAsync(
tenantId, mongo.Id, "ADMIN_PASSWORD", ct);
// Execute mongosh command on the primary to create the user with readWrite role.
string script = $$"""
db.getSiblingDB("{{databaseName}}").createUser({
user: "{{owner}}",
pwd: "{{password}}",
roles: [{ role: "readWrite", db: "{{databaseName}}" }]
})
""";
await k8sFactory.ExecuteMongoAsync(
mongo.Name, mongo.Namespace, script, mongo.KubernetesCluster.Kubeconfig!,
username: "admin", password: adminPassword, ct);
// Mark as ready.
database.Status = MongoDatabaseStatus.Ready;
await db.SaveChangesAsync(ct);
// Store connection credentials in the vault, tagged for K8s sync.
string k8sSecretName = $"{mongo.Name}-{databaseName}-credentials";
// Community Operator creates a headless service named {name}-svc.
// The replica set name equals the MongoDBCommunity resource name.
string host = $"{mongo.Name}-svc.{mongo.Namespace}.svc.cluster.local";
await vaultService.InitializeVaultAsync(tenantId, ct);
await StoreDatabaseSecretAsync(tenantId, database.Id, "HOST", host, k8sSecretName, mongo.Namespace, ct);
await StoreDatabaseSecretAsync(tenantId, database.Id, "PORT", "27017", k8sSecretName, mongo.Namespace, ct);
await StoreDatabaseSecretAsync(tenantId, database.Id, "DATABASE", databaseName, k8sSecretName, mongo.Namespace, ct);
await StoreDatabaseSecretAsync(tenantId, database.Id, "USERNAME", owner, k8sSecretName, mongo.Namespace, ct);
await StoreDatabaseSecretAsync(tenantId, database.Id, "PASSWORD", password, k8sSecretName, mongo.Namespace, ct);
await StoreDatabaseSecretAsync(tenantId, database.Id, "CONNECTION_STRING",
$"mongodb://{owner}:{password}@{host}:27017/{databaseName}?replicaSet={mongo.Name}&authSource={databaseName}",
k8sSecretName, mongo.Namespace, ct);
return database;
}
/// <summary>
/// Deletes a database user from a MongoDB cluster. Drops the user
/// via mongosh and removes all associated vault secrets.
/// </summary>
public async Task DeleteDatabaseAsync(
Guid tenantId, Guid mongoClusterId, Guid databaseId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoCluster mongo = await db.MongoClusters
.Include(c => c.KubernetesCluster)
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct)
?? throw new InvalidOperationException("MongoDB cluster not found.");
MongoDatabase database = await db.MongoDatabases
.FirstOrDefaultAsync(d => d.Id == databaseId && d.MongoClusterId == mongo.Id, ct)
?? throw new InvalidOperationException("Database not found.");
// Drop the user from MongoDB. We don't drop the database itself because
// MongoDB doesn't require explicit database creation — it exists implicitly.
string? adminPassword = await vaultService.GetMongoClusterSecretValueAsync(
tenantId, mongo.Id, "ADMIN_PASSWORD", ct);
string script = $$"""
db.getSiblingDB("{{database.Name}}").dropUser("{{database.Owner}}")
""";
await k8sFactory.ExecuteMongoAsync(
mongo.Name, mongo.Namespace, script, mongo.KubernetesCluster.Kubeconfig!,
username: "admin", password: adminPassword, ct);
// Remove vault secrets.
await DeleteDatabaseSecretsAsync(database.Id, ct);
// Remove the database record.
db.MongoDatabases.Remove(database);
await db.SaveChangesAsync(ct);
}
// ──────── Queries ────────
/// <summary>
/// Gets all managed MongoDB clusters for a tenant, including their databases.
/// </summary>
public async Task<List<MongoCluster>> GetClustersAsync(Guid tenantId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
return await db.MongoClusters
.Include(c => c.KubernetesCluster)
.ThenInclude(k => k.Environment)
.Include(c => c.StorageLink)
.Include(c => c.Databases)
.Include(c => c.Backups.OrderByDescending(b => b.StartedAt).Take(5))
.Where(c => c.TenantId == tenantId)
.OrderBy(c => c.Name)
.ToListAsync(ct);
}
/// <summary>
/// Gets the live detail for a Percona MongoDB cluster including pod status.
/// Queries the PerconaServerMongoDB CRD status and lists pods.
/// </summary>
public async Task<MongoClusterDetail?> GetClusterDetailAsync(
Guid tenantId, Guid mongoClusterId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoCluster? cluster = await db.MongoClusters
.Include(c => c.KubernetesCluster)
.Include(c => c.StorageLink)
.Include(c => c.Databases)
.Include(c => c.Backups.OrderByDescending(b => b.StartedAt).Take(20))
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct);
if (cluster is null)
{
return null;
}
MongoClusterDetail detail = new()
{
Cluster = cluster,
Phase = "Querying...",
Backups = cluster.Backups.OrderByDescending(b => b.StartedAt).ToList()
};
bool clusterReachable = false;
string podsJsonForReconcile = string.Empty;
try
{
// Query the MongoDBCommunity CRD status.
string clusterJson = await k8sFactory.GetJsonAsync(
$"mongodbcommunity.mongodbcommunity.mongodb.com/{cluster.Name}",
cluster.Namespace, cluster.KubernetesCluster.Kubeconfig!, ct: ct);
ParseClusterStatus(clusterJson, detail);
// Query all pods in the namespace and filter by StatefulSet naming convention
// ({name}-N). Label-selector-based queries are unreliable across Community
// Operator versions (some use `app`, some use `app.kubernetes.io/name`).
string podsJson = await k8sFactory.GetJsonAsync(
"pods", cluster.Namespace, cluster.KubernetesCluster.Kubeconfig!, ct: ct);
detail.Pods = ParsePodList(podsJson, cluster.Name);
podsJsonForReconcile = podsJson; // reuse below for backup status reconciliation
// Reconcile DB status now that we have live K8s phase information.
await ReconcileMongoClusterStatusAsync(cluster, detail, ct);
clusterReachable = true;
}
catch (Exception)
{
detail.Phase = "Unable to reach cluster";
}
// Fetch backup job status independently — has its own try/catch so a cluster-query
// failure above does not prevent backup status from being updated.
// Scan K8s Jobs for completed backup jobs so scheduled backups appear in the UI
// even when they were not triggered through EntKube.
List<MongoBackup> k8sBackups = await FetchMongoBackupsFromK8sAsync(cluster, ct);
if (k8sBackups.Count > 0)
detail.Backups = k8sBackups;
await PersistMongoBackupsAsync(cluster.Id, k8sBackups, ct);
// When K8s is reachable, reconcile Running backups using live pod/job data.
if (clusterReachable)
await ReconcileRunningBackupsFromK8sAsync(cluster, podsJsonForReconcile, ct);
// Always run the stale check regardless of K8s reachability.
// A backup still Running after 25 h is certainly finished (TTL is 24 h).
await MarkStaleRunningBackupsAsync(cluster.Id, ct);
// Always reload from DB after persisting so backup objects carry real GUIDs.
// K8s-fetched records have Id=Guid.Empty; DB records are what restore methods need.
using ApplicationDbContext backupsDb = dbFactory.CreateDbContext();
List<MongoBackup> dbBackups = await backupsDb.MongoBackups
.Where(b => b.MongoClusterId == cluster.Id)
.OrderByDescending(b => b.StartedAt)
.Take(20)
.ToListAsync(ct);
if (dbBackups.Count > 0)
detail.Backups = dbBackups;
return detail;
}
private async Task ReconcileMongoClusterStatusAsync(
MongoCluster cluster, MongoClusterDetail detail, CancellationToken ct)
{
MongoClusterStatus? newStatus = DetermineMongoStatusFromPhase(
detail.Phase, detail.ReadyMembers, cluster.Members, cluster.Status);
if (newStatus is null || newStatus == cluster.Status)
return;
try
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoCluster? tracked = await db.MongoClusters.FindAsync([cluster.Id], ct);
if (tracked is not null && tracked.Status != newStatus.Value)
{
tracked.Status = newStatus.Value;
await db.SaveChangesAsync(ct);
cluster.Status = newStatus.Value;
}
}
catch { }
}
private static MongoClusterStatus? DetermineMongoStatusFromPhase(
string phase, int readyMembers, int expectedMembers, MongoClusterStatus currentStatus)
{
string lower = phase.ToLowerInvariant();
if (lower == "running" && readyMembers >= expectedMembers)
{
if (currentStatus is MongoClusterStatus.Creating
or MongoClusterStatus.Restoring
or MongoClusterStatus.Failed)
{
return MongoClusterStatus.Running;
}
}
if (lower == "failed")
{
if (currentStatus is not MongoClusterStatus.Failed and not MongoClusterStatus.Deleting)
return MongoClusterStatus.Failed;
}
return null;
}
private async Task<List<MongoBackup>> FetchMongoBackupsFromK8sAsync(
MongoCluster cluster, CancellationToken ct)
{
try
{
// Query all jobs in the namespace — label filter is avoided because CronJob-spawned
// jobs may not carry the entkube.io/mongo-cluster label if the CronJob was deployed
// before that label was added to the jobTemplate. We identify our jobs by label OR
// by ownerReference pointing to this cluster's scheduled-backup CronJob.
string jobsJson = await k8sFactory.GetJsonAsync(
"jobs", cluster.Namespace, cluster.KubernetesCluster.Kubeconfig!, ct: ct);
using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(jobsJson);
System.Text.Json.JsonElement root = doc.RootElement;
if (!root.TryGetProperty("items", out System.Text.Json.JsonElement items))
return [];
string scheduledCronJobName = $"{cluster.Name}-scheduled-backup";
List<MongoBackup> result = [];
foreach (System.Text.Json.JsonElement item in items.EnumerateArray())
{
if (!item.TryGetProperty("metadata", out System.Text.Json.JsonElement meta))
continue;
string? name = meta.TryGetProperty("name", out System.Text.Json.JsonElement nameEl)
? nameEl.GetString() : null;
if (string.IsNullOrEmpty(name))
continue;
bool hasClusterLabel = false;
bool isScheduled = false;
if (meta.TryGetProperty("labels", out System.Text.Json.JsonElement labels))
{
// Skip restore jobs.
if (labels.TryGetProperty("entkube.io/restore-source", out _)
|| labels.TryGetProperty("entkube.io/restore-type", out _))
continue;
if (labels.TryGetProperty("entkube.io/mongo-cluster", out System.Text.Json.JsonElement clusterLabel)
&& clusterLabel.GetString() == cluster.Name)
hasClusterLabel = true;
}
// Check ownerReferences: jobs from our scheduled CronJob belong to this cluster
// even when the label is absent (older CronJob deployments).
if (meta.TryGetProperty("ownerReferences", out System.Text.Json.JsonElement owners)
&& owners.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (System.Text.Json.JsonElement owner in owners.EnumerateArray())
{
bool isCronJob = owner.TryGetProperty("kind", out System.Text.Json.JsonElement kindEl)
&& kindEl.GetString() == "CronJob";
bool isOurCronJob = owner.TryGetProperty("name", out System.Text.Json.JsonElement ownerNameEl)
&& ownerNameEl.GetString() == scheduledCronJobName;
if (isCronJob)
{
isScheduled = true;
if (isOurCronJob)
hasClusterLabel = true; // treat as belonging to this cluster
break;
}
}
}
// Skip jobs that don't belong to this cluster.
if (!hasClusterLabel)
continue;
MongoBackupStatus status = MongoBackupStatus.Running;
DateTime? startedAt = null;
DateTime? completedAt = null;
if (item.TryGetProperty("status", out System.Text.Json.JsonElement jobStatus))
{
if (jobStatus.TryGetProperty("startTime", out System.Text.Json.JsonElement startEl)
&& DateTime.TryParse(startEl.GetString(), null,
System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsedStart))
startedAt = parsedStart;
if (jobStatus.TryGetProperty("completionTime", out System.Text.Json.JsonElement doneEl)
&& DateTime.TryParse(doneEl.GetString(), null,
System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsedDone))
completedAt = parsedDone;
if (jobStatus.TryGetProperty("succeeded", out System.Text.Json.JsonElement succEl)
&& succEl.TryGetInt32(out int succeededCount) && succeededCount > 0)
status = MongoBackupStatus.Completed;
else if (jobStatus.TryGetProperty("failed", out System.Text.Json.JsonElement failEl)
&& failEl.TryGetInt32(out int failedCount) && failedCount > 0)
status = MongoBackupStatus.Failed;
else if (completedAt.HasValue)
{
// completionTime is set but succeeded/failed counts not yet visible —
// check conditions array for a definitive Complete or Failed signal.
if (jobStatus.TryGetProperty("conditions", out System.Text.Json.JsonElement conds)
&& conds.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (System.Text.Json.JsonElement cond in conds.EnumerateArray())
{
bool isTrue = cond.TryGetProperty("status", out System.Text.Json.JsonElement condStatus)
&& condStatus.GetString() == "True";
if (!isTrue) continue;
if (cond.TryGetProperty("type", out System.Text.Json.JsonElement condType))
{
string? t = condType.GetString();
if (t == "Complete") { status = MongoBackupStatus.Completed; break; }
if (t == "Failed") { status = MongoBackupStatus.Failed; break; }
}
}
}
// If still undecided but completionTime is set, treat as completed.
if (status == MongoBackupStatus.Running)
status = MongoBackupStatus.Completed;
}
}
result.Add(new MongoBackup
{
Id = Guid.Empty,
MongoClusterId = cluster.Id,
Name = name,
Type = isScheduled ? MongoBackupType.Scheduled : MongoBackupType.OnDemand,
Status = status,
StartedAt = startedAt ?? DateTime.UtcNow,
CompletedAt = completedAt
});
}
return [.. result.OrderByDescending(b => b.StartedAt).Take(20)];
}
catch
{
return [];
}
}
private async Task PersistMongoBackupsAsync(
Guid mongoClusterId, List<MongoBackup> k8sBackups, CancellationToken ct)
{
if (k8sBackups.Count == 0)
return;
foreach (MongoBackup k8s in k8sBackups)
{
if (k8s.Id != Guid.Empty)
continue;
try
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
MongoBackup? existing = await db.MongoBackups
.FirstOrDefaultAsync(
b => b.MongoClusterId == mongoClusterId && b.Name == k8s.Name, ct);
if (existing is null)
{
db.MongoBackups.Add(new MongoBackup
{
Id = Guid.NewGuid(),
MongoClusterId = mongoClusterId,
Name = k8s.Name,
Type = k8s.Type,
Status = k8s.Status,
StartedAt = k8s.StartedAt,
CompletedAt = k8s.CompletedAt
});
await db.SaveChangesAsync(ct);
}
else if (existing.Status != k8s.Status)
{
existing.Status = k8s.Status;
if (k8s.Status != MongoBackupStatus.Running)
existing.CompletedAt = k8s.CompletedAt ?? DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
}
catch { }
}
}
/// <summary>
/// Marks DB backup records still Running after 25 h as Completed.
/// Runs unconditionally (even when K8s is unreachable) because a backup that has been
/// Running for more than the job TTL (86400 s = 24 h) is certainly done.
/// </summary>
private async Task MarkStaleRunningBackupsAsync(Guid mongoClusterId, CancellationToken ct)
{
try
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
DateTime threshold = DateTime.UtcNow.AddHours(-25);
List<MongoBackup> stale = await db.MongoBackups
.Where(b => b.MongoClusterId == mongoClusterId
&& b.Status == MongoBackupStatus.Running
&& b.StartedAt < threshold)
.ToListAsync(ct);
if (stale.Count == 0)
return;
foreach (MongoBackup backup in stale)
{
backup.Status = MongoBackupStatus.Completed;
backup.CompletedAt ??= backup.StartedAt.AddHours(1);
}
await db.SaveChangesAsync(ct);
}
catch { }
}
/// <summary>
/// Reconciles DB backup records still marked Running using two sources:
/// 1. The already-fetched pods JSON — K8s automatically labels job pods with
/// "batch.kubernetes.io/job-name" so we can read pod phase without a separate
/// jobs query and without any special RBAC for the jobs resource.
/// 2. A fallback all-jobs kubectl query for any backup not resolved via pods.
/// Only called when K8s is reachable so we don't falsely update during outages.
/// </summary>
private async Task ReconcileRunningBackupsFromK8sAsync(
MongoCluster cluster, string podsJson, CancellationToken ct)
{
try
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
List<MongoBackup> running = await db.MongoBackups
.Where(b => b.MongoClusterId == cluster.Id && b.Status == MongoBackupStatus.Running)
.ToListAsync(ct);
if (running.Count == 0)
return;
HashSet<string> runningNames = running
.Select(b => b.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// ── Source 1: pod phases from the already-fetched pods JSON ──
// K8s labels every job pod with batch.kubernetes.io/job-name (1.21+) or job-name (older).
// Fallback: match by pod name prefix ({job-name}-{hash}) for distributions that omit labels.
Dictionary<string, string> podPhaseByJobName = []; // job-name → best phase
if (!string.IsNullOrEmpty(podsJson))
{
try
{
using System.Text.Json.JsonDocument podsDoc = System.Text.Json.JsonDocument.Parse(podsJson);
if (podsDoc.RootElement.TryGetProperty("items", out System.Text.Json.JsonElement podItems))
{
foreach (System.Text.Json.JsonElement pod in podItems.EnumerateArray())
{
if (!pod.TryGetProperty("metadata", out System.Text.Json.JsonElement podMeta)) continue;
string? phase = null;
if (pod.TryGetProperty("status", out System.Text.Json.JsonElement podStatus)
&& podStatus.TryGetProperty("phase", out System.Text.Json.JsonElement phaseEl))
phase = phaseEl.GetString();
if (phase is null) continue;
string? jobName = null;
// Primary: label-based lookup (K8s 1.21+ and older variants).
if (podMeta.TryGetProperty("labels", out System.Text.Json.JsonElement podLabels))
{
if (podLabels.TryGetProperty("batch.kubernetes.io/job-name", out System.Text.Json.JsonElement jn))
jobName = jn.GetString();
else if (podLabels.TryGetProperty("job-name", out System.Text.Json.JsonElement jn2))
jobName = jn2.GetString();
}
// Fallback: name-prefix matching — pod names are "{job-name}-{5-char-hash}".
if (string.IsNullOrEmpty(jobName)
&& podMeta.TryGetProperty("name", out System.Text.Json.JsonElement podNameEl))
{
string? podName = podNameEl.GetString();
if (!string.IsNullOrEmpty(podName))
{
foreach (string candidate in runningNames)
{
if (podName.StartsWith(candidate + "-", StringComparison.OrdinalIgnoreCase))
{
jobName = candidate;
break;
}
}
}
}
if (string.IsNullOrEmpty(jobName)) continue;
// Keep the "best" phase: Succeeded > Failed > anything else.
if (!podPhaseByJobName.TryGetValue(jobName, out string? bestSoFar)
|| bestSoFar == "Running"
|| (bestSoFar != "Succeeded" && phase == "Succeeded"))
{
podPhaseByJobName[jobName] = phase;
}
}
}
}
catch { }
}
// ── Source 2: all-jobs query as fallback (no label filter) ──
Dictionary<string, (bool Succeeded, bool Failed, DateTime? CompletionTime)> k8sJobs = [];
try
{
string allJobsJson = await k8sFactory.GetJsonAsync(
"jobs", cluster.Namespace, cluster.KubernetesCluster.Kubeconfig!, ct: ct);
using System.Text.Json.JsonDocument jobsDoc = System.Text.Json.JsonDocument.Parse(allJobsJson);
if (jobsDoc.RootElement.TryGetProperty("items", out System.Text.Json.JsonElement jobItems))
{
foreach (System.Text.Json.JsonElement item in jobItems.EnumerateArray())
{
if (!item.TryGetProperty("metadata", out System.Text.Json.JsonElement meta)) continue;
if (!meta.TryGetProperty("name", out System.Text.Json.JsonElement nameEl)) continue;
string? name = nameEl.GetString();
if (string.IsNullOrEmpty(name)) continue;
bool succeeded = false, failed = false;
DateTime? completionTime = null;
if (item.TryGetProperty("status", out System.Text.Json.JsonElement jobStatus))
{
succeeded = jobStatus.TryGetProperty("succeeded", out System.Text.Json.JsonElement s)
&& s.TryGetInt32(out int sv) && sv > 0;
failed = jobStatus.TryGetProperty("failed", out System.Text.Json.JsonElement f)
&& f.TryGetInt32(out int fv) && fv > 0;
if (jobStatus.TryGetProperty("completionTime", out System.Text.Json.JsonElement ct2)
&& DateTime.TryParse(ct2.GetString(), null,
System.Globalization.DateTimeStyles.RoundtripKind, out DateTime ct3))
completionTime = ct3;
}
k8sJobs[name] = (succeeded, failed, completionTime);
}
}
}
catch { } // jobs RBAC unavailable or other error — pods source above takes over
bool changed = false;
foreach (MongoBackup backup in running)
{
// Check pod phase first (most reliable, no extra kubectl call).
if (podPhaseByJobName.TryGetValue(backup.Name, out string? podPhase))
{
if (podPhase == "Succeeded")
{
backup.Status = MongoBackupStatus.Completed;
backup.CompletedAt ??= DateTime.UtcNow;
changed = true;
continue;
}
if (podPhase == "Failed")
{
backup.Status = MongoBackupStatus.Failed;
changed = true;
continue;
}
// Pod still Running → leave as Running
continue;
}
// Fall back to jobs query result.
if (k8sJobs.TryGetValue(backup.Name, out var js))
{
if (js.Succeeded || js.CompletionTime.HasValue)
{
backup.Status = MongoBackupStatus.Completed;
backup.CompletedAt ??= js.CompletionTime ?? DateTime.UtcNow;
changed = true;
}
else if (js.Failed)
{
backup.Status = MongoBackupStatus.Failed;
changed = true;
}
// else genuinely still active → leave as Running
continue;
}
// Job gone from K8s (TTL-deleted) but not yet old enough for stale check →
// MarkStaleRunningBackupsAsync handles this unconditionally after this method.
}
if (changed)
await db.SaveChangesAsync(ct);
}
catch { }
}
// ──────── Private Helpers ────────
/// <summary>
/// Ensures the S3 storage credentials are available as a Kubernetes Secret
/// in the target namespace so backup Jobs can access the bucket.
/// </summary>
private async Task EnsureStorageSecretsInK8sAsync(
Guid tenantId, StorageLink storageLink, string secretName, string ns,
string kubeconfig, CancellationToken ct)
{
List<VaultSecret> secrets = await vaultService.GetStorageLinkSecretsAsync(tenantId, storageLink.Id, ct);
foreach (VaultSecret secret in secrets)
{
if (secret.Name == "ACCESS_KEY" || secret.Name == "SECRET_KEY")
{
await vaultService.ConfigureKubernetesSyncAsync(secret.Id, true, secretName, ns, ct);
}
}
string? accessKey = await vaultService.GetStorageLinkSecretValueAsync(tenantId, storageLink.Id, "ACCESS_KEY", ct);
string? secretKey = await vaultService.GetStorageLinkSecretValueAsync(tenantId, storageLink.Id, "SECRET_KEY", ct);
if (accessKey is null || secretKey is null)
{
throw new InvalidOperationException(
"S3 credentials (ACCESS_KEY and SECRET_KEY) are missing from the storage link vault. " +
"Add them via the Storage tab before creating a backup-enabled cluster.");
}
string accessKeyB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(accessKey));
string secretKeyB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(secretKey));
StringBuilder sb = new();
sb.AppendLine("apiVersion: v1");
sb.AppendLine("kind: Secret");
sb.AppendLine("metadata:");
sb.AppendLine($" name: {secretName}");
sb.AppendLine($" namespace: {ns}");
sb.AppendLine("type: Opaque");
sb.AppendLine("data:");
sb.AppendLine($" ACCESS_KEY: {accessKeyB64}");
sb.AppendLine($" SECRET_KEY: {secretKeyB64}");
await k8sFactory.ApplyManifestAsync(sb.ToString(), kubeconfig, ct);
}
/// <summary>
/// Stores a single database credential in the vault with K8s sync configuration.
/// </summary>
private async Task StoreDatabaseSecretAsync(
Guid tenantId, Guid databaseId, string name, string value,
string k8sSecretName, string k8sNamespace, CancellationToken ct)
{
await vaultService.SetMongoDatabaseSecretAsync(
tenantId, databaseId, name, value, k8sSecretName, k8sNamespace, ct);
}
/// <summary>
/// Removes all vault secrets associated with a MongoDB database.
/// </summary>
private async Task DeleteDatabaseSecretsAsync(Guid databaseId, CancellationToken ct)
{
using ApplicationDbContext secretDb = dbFactory.CreateDbContext();
List<VaultSecret> secrets = await secretDb.VaultSecrets
.Where(s => s.MongoDatabaseId == databaseId)
.ToListAsync(ct);
secretDb.VaultSecrets.RemoveRange(secrets);
await secretDb.SaveChangesAsync(ct);
}
/// <summary>
/// Removes all vault secrets scoped to a managed MongoDB cluster (e.g. the admin password).
/// </summary>
private async Task DeleteClusterSecretsAsync(Guid mongoClusterId, CancellationToken ct)
{
using ApplicationDbContext secretDb = dbFactory.CreateDbContext();
List<VaultSecret> secrets = await secretDb.VaultSecrets
.Where(s => s.MongoClusterId == mongoClusterId)
.ToListAsync(ct);
secretDb.VaultSecrets.RemoveRange(secrets);
await secretDb.SaveChangesAsync(ct);
}
/// <summary>
/// Generates a secure random password for database users.
/// </summary>
private static string GeneratePassword()
{
byte[] bytes = RandomNumberGenerator.GetBytes(24);
return Convert.ToBase64String(bytes).Replace("+", "x").Replace("/", "y")[..32];
}
// ──────── Manifest Builders ────────
/// <summary>
/// Builds a multi-document manifest that creates the ServiceAccount, Role, and
/// RoleBinding required by the MongoDB agent running inside each pod.
///
/// The Community Operator provisions these resources only in its own installation
/// namespace. When the MongoDB cluster lives in a different namespace the agent's
/// readiness probe panics with "secrets forbidden" because the ServiceAccount has
/// no permissions there. Applying these three resources fixes that.
///
/// Permissions mirror the mongodb-database Role in the operator's Helm chart:
/// - secrets: get (agent reads the mongodb-config automation-config secret)
/// - pods: get, patch, delete (agent manages pod lifecycle during elections)
/// </summary>
private static string BuildServiceAccountManifest(string ns)
{
StringBuilder sb = new();
sb.AppendLine("apiVersion: v1");
sb.AppendLine("kind: ServiceAccount");
sb.AppendLine("metadata:");
sb.AppendLine(" name: mongodb-database");
sb.AppendLine($" namespace: {ns}");
sb.AppendLine("---");
sb.AppendLine("apiVersion: rbac.authorization.k8s.io/v1");
sb.AppendLine("kind: Role");
sb.AppendLine("metadata:");
sb.AppendLine(" name: mongodb-database");
sb.AppendLine($" namespace: {ns}");
sb.AppendLine("rules:");
sb.AppendLine(" - apiGroups: [\"\"]");
sb.AppendLine(" resources: [\"secrets\"]");
sb.AppendLine(" verbs: [\"get\"]");
sb.AppendLine(" - apiGroups: [\"\"]");
sb.AppendLine(" resources: [\"pods\"]");
sb.AppendLine(" verbs: [\"get\", \"patch\", \"delete\"]");
sb.AppendLine("---");
sb.AppendLine("apiVersion: rbac.authorization.k8s.io/v1");
sb.AppendLine("kind: RoleBinding");
sb.AppendLine("metadata:");
sb.AppendLine(" name: mongodb-database");
sb.AppendLine($" namespace: {ns}");
sb.AppendLine("subjects:");
sb.AppendLine(" - kind: ServiceAccount");
sb.AppendLine(" name: mongodb-database");
sb.AppendLine($" namespace: {ns}");
sb.AppendLine("roleRef:");
sb.AppendLine(" kind: Role");
sb.AppendLine(" name: mongodb-database");
sb.AppendLine(" apiGroup: rbac.authorization.k8s.io");
return sb.ToString();
}
/// <summary>
/// Builds the K8s Secret manifest that holds the MongoDB admin password.
/// Must be applied before the MongoDBCommunity CRD — the operator reads this
/// Secret during startup to configure SCRAM credentials.
/// </summary>
private static string BuildAdminSecretManifest(string name, string ns, string password)
{
StringBuilder sb = new();
sb.AppendLine("apiVersion: v1");
sb.AppendLine("kind: Secret");
sb.AppendLine("metadata:");
sb.AppendLine($" name: {name}-admin-password");
sb.AppendLine($" namespace: {ns}");
sb.AppendLine("type: Opaque");
sb.AppendLine("stringData:");
sb.AppendLine($" password: \"{password}\"");
return sb.ToString();
}
/// <summary>
/// Builds the MongoDBCommunity CRD manifest. Defines an admin user referencing the
/// pre-existing {name}-admin-password Secret so the operator can configure SCRAM
/// authentication and create the StatefulSet.
/// </summary>
private static string BuildClusterManifest(MongoCluster mongo, StorageLink? storageLink, string? s3SecretName)
{
// Community Operator requires a full X.Y.Z version; coerce X.Y → X.Y.0.
string version = mongo.MongoVersion.Count(c => c == '.') < 2
? mongo.MongoVersion + ".0"
: mongo.MongoVersion;
StringBuilder sb = new();
sb.AppendLine("apiVersion: mongodbcommunity.mongodb.com/v1");
sb.AppendLine("kind: MongoDBCommunity");
sb.AppendLine("metadata:");
sb.AppendLine($" name: {mongo.Name}");
sb.AppendLine($" namespace: {mongo.Namespace}");
sb.AppendLine("spec:");
sb.AppendLine($" members: {mongo.Members}");
sb.AppendLine(" type: ReplicaSet");
sb.AppendLine($" version: \"{version}\"");
sb.AppendLine(" security:");
sb.AppendLine(" authentication:");
sb.AppendLine(" modes: [\"SCRAM\"]");
sb.AppendLine(" users:");
sb.AppendLine(" - name: admin");
sb.AppendLine(" db: admin");
sb.AppendLine(" passwordSecretRef:");
sb.AppendLine($" name: {mongo.Name}-admin-password");
sb.AppendLine($" scramCredentialsSecretName: {mongo.Name}-scram");
sb.AppendLine(" roles:");
sb.AppendLine(" - name: clusterAdmin");
sb.AppendLine(" db: admin");
sb.AppendLine(" - name: userAdminAnyDatabase");
sb.AppendLine(" db: admin");
sb.AppendLine(" - name: readWriteAnyDatabase");
sb.AppendLine(" db: admin");
sb.AppendLine(" - name: dbAdminAnyDatabase");
sb.AppendLine(" db: admin");
sb.AppendLine(" statefulSet:");
sb.AppendLine(" spec:");
sb.AppendLine(" volumeClaimTemplates:");
sb.AppendLine(" - metadata:");
sb.AppendLine(" name: data-volume");
sb.AppendLine(" spec:");
sb.AppendLine(" accessModes: [\"ReadWriteOnce\"]");
sb.AppendLine(" resources:");
sb.AppendLine(" requests:");
sb.AppendLine($" storage: {mongo.StorageSize}");
return sb.ToString();
}
/// <summary>
/// Builds a Kubernetes Job manifest that runs mongodump and uploads the gzip
/// archive to S3. Uses a two-stage approach: an init container runs mongodump
/// (authenticating via the admin Secret) into a shared emptyDir volume, then the
/// main container uploads via aws-cli. Archive stored at {bucket}/{name}/{backupName}.archive.
/// </summary>
private static string BuildBackupManifest(
string backupName, MongoCluster mongo, StorageLink storageLink, string s3SecretName)
{
string host = $"{mongo.Name}-svc.{mongo.Namespace}.svc.cluster.local";
string mongoUri = $"mongodb://admin:${{MONGO_ADMIN_PASSWORD}}@{host}:27017/?authSource=admin&replicaSet={mongo.Name}";
string s3Path = $"s3://{storageLink.BucketName}/{mongo.Name}/{backupName}.archive";
StringBuilder sb = new();
sb.AppendLine("apiVersion: batch/v1");
sb.AppendLine("kind: Job");
sb.AppendLine("metadata:");
sb.AppendLine($" name: {backupName}");
sb.AppendLine($" namespace: {mongo.Namespace}");
sb.AppendLine(" labels:");
sb.AppendLine($" entkube.io/mongo-cluster: {mongo.Name}");
sb.AppendLine($" entkube.io/backup-type: on-demand");
sb.AppendLine("spec:");
sb.AppendLine(" backoffLimit: 0");
sb.AppendLine(" ttlSecondsAfterFinished: 86400");
sb.AppendLine(" template:");
sb.AppendLine(" spec:");
sb.AppendLine(" restartPolicy: Never");
sb.AppendLine(" volumes:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" emptyDir: {}");
sb.AppendLine(" initContainers:");
sb.AppendLine(" - name: dump");
sb.AppendLine($" image: mongo:{mongo.MongoVersion}");
sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]");
sb.AppendLine($" args: [\"mongodump --uri=\\\"{mongoUri}\\\" --gzip --archive=/backup/dump.archive\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {mongo.Name}-admin-password");
sb.AppendLine(" key: password");
sb.AppendLine(" volumeMounts:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" mountPath: /backup");
sb.AppendLine(" containers:");
sb.AppendLine(" - name: upload");
sb.AppendLine(" image: amazon/aws-cli");
sb.AppendLine(" command: [\"aws\", \"s3\", \"cp\", \"/backup/dump.archive\",");
sb.AppendLine($" \"{s3Path}\", \"--endpoint-url\", \"{storageLink.Endpoint}\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: AWS_ACCESS_KEY_ID");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {s3SecretName}");
sb.AppendLine(" key: ACCESS_KEY");
sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {s3SecretName}");
sb.AppendLine(" key: SECRET_KEY");
sb.AppendLine(" - name: AWS_DEFAULT_REGION");
sb.AppendLine($" value: \"{storageLink.Region ?? "us-east-1"}\"");
sb.AppendLine(" volumeMounts:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" mountPath: /backup");
return sb.ToString();
}
/// <summary>
/// Builds a Kubernetes Job manifest that downloads a backup archive from S3 and
/// runs mongorestore --drop. The init container fetches the archive via aws-cli,
/// then the main container runs mongorestore (authenticated via the admin Secret).
/// </summary>
private static string BuildRestoreManifest(
string restoreName, MongoCluster mongo, StorageLink storageLink,
string sourceBackupName, string s3SecretName)
{
string host = $"{mongo.Name}-svc.{mongo.Namespace}.svc.cluster.local";
string mongoUri = $"mongodb://admin:${{MONGO_ADMIN_PASSWORD}}@{host}:27017/?authSource=admin&replicaSet={mongo.Name}";
string mongoshUri = $"mongodb://{host}:27017/?replicaSet={mongo.Name}";
string s3Path = $"s3://{storageLink.BucketName}/{mongo.Name}/{sourceBackupName}.archive";
StringBuilder sb = new();
sb.AppendLine("apiVersion: batch/v1");
sb.AppendLine("kind: Job");
sb.AppendLine("metadata:");
sb.AppendLine($" name: {restoreName}");
sb.AppendLine($" namespace: {mongo.Namespace}");
sb.AppendLine(" labels:");
sb.AppendLine($" entkube.io/mongo-cluster: {mongo.Name}");
sb.AppendLine($" entkube.io/restore-source: {sourceBackupName}");
sb.AppendLine("spec:");
sb.AppendLine(" backoffLimit: 0");
sb.AppendLine(" ttlSecondsAfterFinished: 86400");
sb.AppendLine(" template:");
sb.AppendLine(" spec:");
sb.AppendLine(" restartPolicy: Never");
sb.AppendLine(" volumes:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" emptyDir: {}");
sb.AppendLine(" initContainers:");
sb.AppendLine(" - name: download");
sb.AppendLine(" image: amazon/aws-cli");
sb.AppendLine(" command: [\"aws\", \"s3\", \"cp\",");
sb.AppendLine($" \"{s3Path}\", \"/backup/dump.archive\", \"--endpoint-url\", \"{storageLink.Endpoint}\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: AWS_ACCESS_KEY_ID");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {s3SecretName}");
sb.AppendLine(" key: ACCESS_KEY");
sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {s3SecretName}");
sb.AppendLine(" key: SECRET_KEY");
sb.AppendLine(" - name: AWS_DEFAULT_REGION");
sb.AppendLine($" value: \"{storageLink.Region ?? "us-east-1"}\"");
sb.AppendLine(" volumeMounts:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" mountPath: /backup");
sb.AppendLine(" - name: wait-for-mongo");
sb.AppendLine($" image: mongo:{mongo.MongoVersion}");
sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]");
sb.AppendLine($" args: [\"until mongosh \\\"{mongoshUri}\\\" --username admin --password \\\"${{MONGO_ADMIN_PASSWORD}}\\\" --authenticationDatabase admin --eval 'db.adminCommand({{ping:1}})' --quiet; do echo 'waiting for MongoDB...'; sleep 5; done\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {mongo.Name}-admin-password");
sb.AppendLine(" key: password");
sb.AppendLine(" containers:");
sb.AppendLine(" - name: restore");
sb.AppendLine($" image: mongo:{mongo.MongoVersion}");
sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]");
sb.AppendLine($" args: [\"mongorestore --uri=\\\"{mongoUri}\\\" --gzip --archive=/backup/dump.archive --drop\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {mongo.Name}-admin-password");
sb.AppendLine(" key: password");
sb.AppendLine(" volumeMounts:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" mountPath: /backup");
return sb.ToString();
}
/// <summary>
/// Builds a restore Job that downloads a backup from the SOURCE cluster's S3 path
/// and restores it into the TARGET cluster's service. Used for "restore to new cluster"
/// where the source and target are different clusters.
/// </summary>
private static string BuildCrossClusterRestoreManifest(
string restoreName, MongoCluster targetCluster, string sourceClusterName,
StorageLink storageLink, string sourceBackupName, string s3SecretName)
{
string host = $"{targetCluster.Name}-svc.{targetCluster.Namespace}.svc.cluster.local";
string mongoUri = $"mongodb://admin:${{MONGO_ADMIN_PASSWORD}}@{host}:27017/?authSource=admin&replicaSet={targetCluster.Name}";
string mongoshUri = $"mongodb://{host}:27017/?replicaSet={targetCluster.Name}";
string s3Path = $"s3://{storageLink.BucketName}/{sourceClusterName}/{sourceBackupName}.archive";
StringBuilder sb = new();
sb.AppendLine("apiVersion: batch/v1");
sb.AppendLine("kind: Job");
sb.AppendLine("metadata:");
sb.AppendLine($" name: {restoreName}");
sb.AppendLine($" namespace: {targetCluster.Namespace}");
sb.AppendLine(" labels:");
sb.AppendLine($" entkube.io/mongo-cluster: {targetCluster.Name}");
sb.AppendLine($" entkube.io/restore-source: {sourceBackupName}");
sb.AppendLine("spec:");
sb.AppendLine(" backoffLimit: 2");
sb.AppendLine(" ttlSecondsAfterFinished: 86400");
sb.AppendLine(" template:");
sb.AppendLine(" spec:");
sb.AppendLine(" restartPolicy: Never");
sb.AppendLine(" volumes:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" emptyDir: {}");
sb.AppendLine(" initContainers:");
sb.AppendLine(" - name: download");
sb.AppendLine(" image: amazon/aws-cli");
sb.AppendLine(" command: [\"aws\", \"s3\", \"cp\",");
sb.AppendLine($" \"{s3Path}\", \"/backup/dump.archive\", \"--endpoint-url\", \"{storageLink.Endpoint}\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: AWS_ACCESS_KEY_ID");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {s3SecretName}");
sb.AppendLine(" key: ACCESS_KEY");
sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {s3SecretName}");
sb.AppendLine(" key: SECRET_KEY");
sb.AppendLine(" - name: AWS_DEFAULT_REGION");
sb.AppendLine($" value: \"{storageLink.Region ?? "us-east-1"}\"");
sb.AppendLine(" volumeMounts:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" mountPath: /backup");
sb.AppendLine(" - name: wait-for-mongo");
sb.AppendLine($" image: mongo:{targetCluster.MongoVersion}");
sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]");
sb.AppendLine($" args: [\"until mongosh \\\"{mongoshUri}\\\" --username admin --password \\\"${{MONGO_ADMIN_PASSWORD}}\\\" --authenticationDatabase admin --eval 'db.adminCommand({{ping:1}})' --quiet; do echo 'waiting for MongoDB...'; sleep 5; done\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {targetCluster.Name}-admin-password");
sb.AppendLine(" key: password");
sb.AppendLine(" containers:");
sb.AppendLine(" - name: restore");
sb.AppendLine($" image: mongo:{targetCluster.MongoVersion}");
sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]");
sb.AppendLine($" args: [\"mongorestore --uri=\\\"{mongoUri}\\\" --gzip --archive=/backup/dump.archive --drop\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {targetCluster.Name}-admin-password");
sb.AppendLine(" key: password");
sb.AppendLine(" volumeMounts:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" mountPath: /backup");
return sb.ToString();
}
/// <summary>
/// Builds a restore Job that downloads a backup from S3 and runs mongorestore
/// against an external MongoDB URI. The URI must include credentials if auth is
/// required (e.g. mongodb://user:pass@host:27017/). No cluster-local admin
/// secret is needed — the URI carries all authentication information.
/// </summary>
private static string BuildExternalRestoreManifest(
string restoreName, MongoCluster sourceCluster, StorageLink storageLink,
string sourceBackupName, string s3SecretName, string externalMongoUri)
{
string s3Path = $"s3://{storageLink.BucketName}/{sourceCluster.Name}/{sourceBackupName}.archive";
StringBuilder sb = new();
sb.AppendLine("apiVersion: batch/v1");
sb.AppendLine("kind: Job");
sb.AppendLine("metadata:");
sb.AppendLine($" name: {restoreName}");
sb.AppendLine($" namespace: {sourceCluster.Namespace}");
sb.AppendLine(" labels:");
sb.AppendLine($" entkube.io/mongo-cluster: {sourceCluster.Name}");
sb.AppendLine($" entkube.io/restore-type: external");
sb.AppendLine("spec:");
sb.AppendLine(" backoffLimit: 0");
sb.AppendLine(" ttlSecondsAfterFinished: 86400");
sb.AppendLine(" template:");
sb.AppendLine(" spec:");
sb.AppendLine(" restartPolicy: Never");
sb.AppendLine(" volumes:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" emptyDir: {}");
sb.AppendLine(" initContainers:");
sb.AppendLine(" - name: download");
sb.AppendLine(" image: amazon/aws-cli");
sb.AppendLine(" command: [\"aws\", \"s3\", \"cp\",");
sb.AppendLine($" \"{s3Path}\", \"/backup/dump.archive\", \"--endpoint-url\", \"{storageLink.Endpoint}\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: AWS_ACCESS_KEY_ID");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {s3SecretName}");
sb.AppendLine(" key: ACCESS_KEY");
sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {s3SecretName}");
sb.AppendLine(" key: SECRET_KEY");
sb.AppendLine(" - name: AWS_DEFAULT_REGION");
sb.AppendLine($" value: \"{storageLink.Region ?? "us-east-1"}\"");
sb.AppendLine(" volumeMounts:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" mountPath: /backup");
sb.AppendLine(" containers:");
sb.AppendLine(" - name: restore");
sb.AppendLine($" image: mongo:{sourceCluster.MongoVersion}");
sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]");
sb.AppendLine($" args: [\"mongorestore --uri='{externalMongoUri}' --gzip --archive=/backup/dump.archive --drop\"]");
sb.AppendLine(" volumeMounts:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" mountPath: /backup");
return sb.ToString();
}
/// <summary>
/// Builds a CronJob manifest that runs mongodump on a schedule and uploads
/// each archive to S3 at {bucket}/{name}/scheduled-{timestamp}.archive.
/// The CronJob uses the mongo image matching the cluster version so upgrades
/// keep the dump tool and server in sync.
/// </summary>
private static string BuildScheduledBackupCronJobManifest(
MongoCluster mongo, StorageLink storageLink, string s3SecretName)
{
string host = $"{mongo.Name}-svc.{mongo.Namespace}.svc.cluster.local";
string mongoUri = $"mongodb://admin:${{MONGO_ADMIN_PASSWORD}}@{host}:27017/?authSource=admin&replicaSet={mongo.Name}";
string s3Base = $"s3://{storageLink.BucketName}/{mongo.Name}";
StringBuilder sb = new();
sb.AppendLine("apiVersion: batch/v1");
sb.AppendLine("kind: CronJob");
sb.AppendLine("metadata:");
sb.AppendLine($" name: {mongo.Name}-scheduled-backup");
sb.AppendLine($" namespace: {mongo.Namespace}");
sb.AppendLine(" labels:");
sb.AppendLine($" entkube.io/mongo-cluster: {mongo.Name}");
sb.AppendLine("spec:");
// CNPG uses a 6-field cron (seconds included); Kubernetes CronJob only accepts 5 fields.
// Drop the leading seconds field when the schedule has 6 parts.
string cronSchedule = mongo.BackupSchedule!;
string[] parts = cronSchedule.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 6) cronSchedule = string.Join(' ', parts[1..]);
sb.AppendLine($" schedule: \"{cronSchedule}\"");
sb.AppendLine(" concurrencyPolicy: Forbid");
sb.AppendLine(" jobTemplate:");
sb.AppendLine(" metadata:");
sb.AppendLine(" labels:");
sb.AppendLine($" entkube.io/mongo-cluster: {mongo.Name}");
sb.AppendLine(" spec:");
sb.AppendLine(" backoffLimit: 0");
sb.AppendLine(" ttlSecondsAfterFinished: 86400");
sb.AppendLine(" template:");
sb.AppendLine(" spec:");
sb.AppendLine(" restartPolicy: Never");
sb.AppendLine(" volumes:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" emptyDir: {}");
sb.AppendLine(" initContainers:");
sb.AppendLine(" - name: dump");
sb.AppendLine($" image: mongo:{mongo.MongoVersion}");
sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]");
sb.AppendLine($" args: [\"mongodump --uri=\\\"{mongoUri}\\\" --gzip --archive=/backup/dump.archive\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {mongo.Name}-admin-password");
sb.AppendLine(" key: password");
sb.AppendLine(" volumeMounts:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" mountPath: /backup");
sb.AppendLine(" containers:");
sb.AppendLine(" - name: upload");
sb.AppendLine(" image: amazon/aws-cli");
sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]");
sb.AppendLine($" args: [\"aws s3 cp /backup/dump.archive {s3Base}/scheduled-$(date -u +%Y%m%dT%H%M%SZ).archive --endpoint-url {storageLink.Endpoint}\"]");
sb.AppendLine(" env:");
sb.AppendLine(" - name: AWS_ACCESS_KEY_ID");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {s3SecretName}");
sb.AppendLine(" key: ACCESS_KEY");
sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY");
sb.AppendLine(" valueFrom:");
sb.AppendLine(" secretKeyRef:");
sb.AppendLine($" name: {s3SecretName}");
sb.AppendLine(" key: SECRET_KEY");
sb.AppendLine(" - name: AWS_DEFAULT_REGION");
sb.AppendLine($" value: \"{storageLink.Region ?? "us-east-1"}\"");
sb.AppendLine(" volumeMounts:");
sb.AppendLine(" - name: backup-data");
sb.AppendLine(" mountPath: /backup");
return sb.ToString();
}
// ──────── JSON Parsing ────────
/// <summary>
/// Parses the MongoDBCommunity CRD JSON to extract phase and ready member count.
/// Community Operator status fields: status.phase, status.currentMongoDBMembers.
/// </summary>
private static void ParseClusterStatus(string json, MongoClusterDetail detail)
{
using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json);
System.Text.Json.JsonElement root = doc.RootElement;
if (root.TryGetProperty("status", out System.Text.Json.JsonElement status))
{
if (status.TryGetProperty("phase", out System.Text.Json.JsonElement phase))
{
detail.Phase = phase.GetString() ?? "Unknown";
}
if (status.TryGetProperty("currentMongoDBMembers", out System.Text.Json.JsonElement members))
{
detail.ReadyMembers = members.GetInt32();
}
if (status.TryGetProperty("message", out System.Text.Json.JsonElement messageEl))
{
detail.Message = messageEl.GetString();
}
// Fall back to the most recent condition message if no top-level message.
if (string.IsNullOrEmpty(detail.Message)
&& status.TryGetProperty("conditions", out System.Text.Json.JsonElement conditions)
&& conditions.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (System.Text.Json.JsonElement condition in conditions.EnumerateArray())
{
if (condition.TryGetProperty("message", out System.Text.Json.JsonElement condMsg))
{
string? msg = condMsg.GetString();
if (!string.IsNullOrEmpty(msg))
{
detail.Message = msg;
break;
}
}
}
}
}
// Derive the endpoint from the resource metadata name, which is stored on the cluster record.
if (root.TryGetProperty("metadata", out System.Text.Json.JsonElement metadata)
&& metadata.TryGetProperty("name", out System.Text.Json.JsonElement nameEl)
&& metadata.TryGetProperty("namespace", out System.Text.Json.JsonElement nsEl))
{
detail.Endpoint = $"{nameEl.GetString()}-svc.{nsEl.GetString()}.svc.cluster.local:27017";
}
}
/// <summary>
/// Parses a kubectl "get pods -o json" response into a list of MongoPodInfo.
/// </summary>
private static List<MongoPodInfo> ParsePodList(string json, string clusterName)
{
List<MongoPodInfo> pods = [];
using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json);
System.Text.Json.JsonElement root = doc.RootElement;
if (!root.TryGetProperty("items", out System.Text.Json.JsonElement items))
{
return pods;
}
string prefix = clusterName + "-";
foreach (System.Text.Json.JsonElement item in items.EnumerateArray())
{
string podName = item.GetProperty("metadata").GetProperty("name").GetString() ?? "";
// Only include StatefulSet member pods: {clusterName}-0, {clusterName}-1, ...
// Skip Jobs, init containers, and any other pods in the namespace.
if (!podName.StartsWith(prefix)) continue;
string ordinal = podName[prefix.Length..];
if (ordinal.Length == 0 || !ordinal.All(char.IsDigit)) continue;
string podStatus = "Unknown";
bool ready = false;
string? node = null;
DateTime? startTime = null;
int restarts = 0;
if (item.TryGetProperty("spec", out System.Text.Json.JsonElement spec)
&& spec.TryGetProperty("nodeName", out System.Text.Json.JsonElement nodeName))
{
node = nodeName.GetString();
}
if (item.TryGetProperty("status", out System.Text.Json.JsonElement statusEl))
{
if (statusEl.TryGetProperty("phase", out System.Text.Json.JsonElement phaseEl))
{
podStatus = phaseEl.GetString() ?? "Unknown";
}
if (statusEl.TryGetProperty("startTime", out System.Text.Json.JsonElement startEl))
{
if (DateTime.TryParse(startEl.GetString(), null,
System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsed))
{
startTime = parsed;
}
}
if (statusEl.TryGetProperty("containerStatuses", out System.Text.Json.JsonElement containers))
{
foreach (System.Text.Json.JsonElement container in containers.EnumerateArray())
{
if (container.TryGetProperty("restartCount", out System.Text.Json.JsonElement rc))
{
restarts += rc.GetInt32();
}
if (container.TryGetProperty("ready", out System.Text.Json.JsonElement readyEl))
{
ready = ready || readyEl.GetBoolean();
}
}
}
}
pods.Add(new MongoPodInfo
{
Name = podName,
Status = podStatus,
Ready = ready,
Node = node,
StartTime = startTime,
Restarts = restarts
});
}
return pods;
}
}
/// <summary>
/// Detail view model for a managed MongoDB cluster.
/// </summary>
public class MongoClusterDetail
{
public required MongoCluster Cluster { get; set; }
public string Phase { get; set; } = "Unknown";
public string? Message { get; set; }
public int ReadyMembers { get; set; }
public string? Endpoint { get; set; }
public List<MongoPodInfo> Pods { get; set; } = [];
public List<MongoBackup> Backups { get; set; } = [];
}
/// <summary>
/// Pod information for a MongoDB cluster member.
/// </summary>
public class MongoPodInfo
{
public required string Name { get; set; }
public string Status { get; set; } = "Unknown";
public bool Ready { get; set; }
public string? Node { get; set; }
public DateTime? StartTime { get; set; }
public int Restarts { get; set; }
}