gateway api fixes

This commit is contained in:
Nils Blomgren
2026-05-21 16:57:50 +02:00
parent 8b94fb8826
commit e0f967482e
68 changed files with 44082 additions and 437 deletions

View File

@@ -3,6 +3,7 @@
@using Microsoft.EntityFrameworkCore
@inject TenantService TenantService
@inject DeploymentService DeploymentService
@inject CnpgService CnpgService
@* ═══════════════════════════════════════════════════════════════════
App Detail — a full-page view for a single application.
@@ -226,6 +227,16 @@
<i class="bi bi-diagram-3 me-1"></i>Resources
</button>
</li>
<li class="nav-item">
<button class="nav-link @(deploymentSection == "databases" ? "active" : "")"
@onclick="OpenDatabasesTab">
<i class="bi bi-database me-1"></i>Databases
@if (databaseBindings is not null && databaseBindings.Count > 0)
{
<span class="badge bg-primary ms-1">@databaseBindings.Count</span>
}
</button>
</li>
</ul>
@* ── Manifests sub-tab ── *@
@@ -393,6 +404,123 @@
</div>
</div>
}
@* ── Databases sub-tab ── *@
@if (deploymentSection == "databases")
{
<div class="card shadow-sm">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-database me-2 text-primary"></i>
<strong>Database Bindings</strong>
</div>
<button class="btn btn-sm btn-outline-primary" @onclick="() => showAddBinding = !showAddBinding">
<i class="bi bi-plus me-1"></i>Attach Database
</button>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(bindingError))
{
<div class="alert alert-danger py-1 small mb-2">@bindingError</div>
}
@* --- Add binding form --- *@
@if (showAddBinding)
{
<div class="card border-primary mb-3">
<div class="card-body">
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Attach a PostgreSQL Database</h6>
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Database</label>
<select class="form-select form-select-sm" @bind="newBindingDatabaseId">
<option value="">Select database...</option>
@if (availableCnpgDatabases is not null)
{
@foreach ((CnpgCluster cluster, CnpgDatabase database) in availableCnpgDatabases)
{
<option value="@database.Id">@cluster.Name / @database.Name</option>
}
}
</select>
</div>
<div class="col-md-6">
<label class="form-label small">Secret name in app namespace</label>
<input class="form-control form-control-sm" @bind="newBindingSecretName"
placeholder="e.g. my-app-db" />
<div class="form-text">The K8s Secret created in <code>@selectedDeployment?.Namespace</code>.</div>
</div>
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-primary" @onclick="AddDatabaseBinding"
disabled="@(newBindingDatabaseId == Guid.Empty || string.IsNullOrWhiteSpace(newBindingSecretName))">
Attach &amp; Sync
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddBinding = false">Cancel</button>
</div>
</div>
</div>
}
@* --- Binding list --- *@
@if (databaseBindings is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (databaseBindings.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-database text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No databases attached. Attach a database to have its credentials automatically synced to this deployment's namespace.</p>
</div>
}
else
{
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th class="small">Database</th>
<th class="small">Secret name</th>
<th class="small">Last synced</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (DatabaseBinding binding in databaseBindings)
{
<tr>
<td class="small">
@if (binding.CnpgDatabase is not null)
{
<i class="bi bi-database me-1 text-primary"></i>
@binding.CnpgDatabase.CnpgCluster.Name<span class="text-muted"> / </span>@binding.CnpgDatabase.Name
}
else if (binding.MongoDatabase is not null)
{
<i class="bi bi-database me-1 text-success"></i>
@binding.MongoDatabase.MongoCluster.Name<span class="text-muted"> / </span>@binding.MongoDatabase.Name
}
</td>
<td><code class="small">@binding.KubernetesSecretName</code></td>
<td class="small text-muted">
@(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never")
</td>
<td>
<button class="btn btn-link btn-sm text-danger p-0" title="Detach"
@onclick="() => RemoveDatabaseBinding(binding.Id)">
<i class="bi bi-x-circle" style="font-size:0.85rem;"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
}
else
{
@@ -602,6 +730,14 @@
// Resource tree
private List<DeploymentResource>? resourceTree;
// Database bindings
private List<DatabaseBinding>? databaseBindings;
private List<(CnpgCluster Cluster, CnpgDatabase Database)>? availableCnpgDatabases;
private bool showAddBinding;
private Guid newBindingDatabaseId;
private string newBindingSecretName = "";
private string? bindingError;
protected override async Task OnInitializedAsync()
{
await LoadEnvironments();
@@ -790,6 +926,80 @@
}
}
// ──────── Database bindings ────────
private async Task OpenDatabasesTab()
{
deploymentSection = "databases";
if (selectedDeployment is not null)
{
databaseBindings = await CnpgService.GetDatabaseBindingsAsync(selectedDeployment.Id);
if (availableCnpgDatabases is null)
{
List<CnpgCluster> clusters = await CnpgService.GetClustersAsync(TenantId);
availableCnpgDatabases = clusters
.SelectMany(c => c.Databases.Select(d => (c, d)))
.ToList();
}
}
}
private async Task AddDatabaseBinding()
{
if (selectedDeployment is null || newBindingDatabaseId == Guid.Empty)
{
return;
}
bindingError = null;
try
{
DatabaseBinding binding = await CnpgService.AddCnpgDatabaseBindingAsync(
selectedDeployment.Id, newBindingDatabaseId, newBindingSecretName);
// Find the cluster that owns this database so we can call sync.
(CnpgCluster cluster, CnpgDatabase _)? match = availableCnpgDatabases?
.FirstOrDefault(x => x.Database.Id == newBindingDatabaseId);
if (match.HasValue)
{
await CnpgService.SyncDatabaseCredentialsToK8sAsync(
TenantId, match.Value.cluster.Id, newBindingDatabaseId);
}
showAddBinding = false;
newBindingDatabaseId = Guid.Empty;
newBindingSecretName = "";
databaseBindings = await CnpgService.GetDatabaseBindingsAsync(selectedDeployment.Id);
}
catch (Exception ex)
{
bindingError = $"Failed to attach database: {ex.Message}";
}
}
private async Task RemoveDatabaseBinding(Guid bindingId)
{
bindingError = null;
try
{
await CnpgService.RemoveDatabaseBindingAsync(bindingId);
if (selectedDeployment is not null)
{
databaseBindings = await CnpgService.GetDatabaseBindingsAsync(selectedDeployment.Id);
}
}
catch (Exception ex)
{
bindingError = $"Failed to detach database: {ex.Message}";
}
}
// ──────── Rendering helpers ────────
private RenderFragment GetTypeBadge(DeploymentType type) => type switch

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -172,6 +172,19 @@ else
title="Manage bucket (CORS, Policy)">
<i class="bi bi-gear"></i>
</button>
<button class="btn btn-sm btn-outline-info border-0 py-0"
@onclick="() => BrowseBuckets(link)"
disabled="@(browsingLinkId.HasValue)"
title="Browse all buckets accessible with these credentials">
@if (browsingLinkId == link.Id)
{
<span class="spinner-border spinner-border-sm"></span>
}
else
{
<i class="bi bi-list-ul"></i>
}
</button>
<button class="btn btn-sm btn-outline-danger border-0 py-0"
@onclick="() => DeleteCleuraS3Bucket(link.Id)"
disabled="@deletingLinkId.HasValue"
@@ -327,6 +340,82 @@ else
</div>
}
@* ── Browse Buckets Panel ── *@
@if (browsedLink is not null)
{
<div class="card shadow-sm mb-4 border-secondary">
<div class="card-header bg-secondary bg-opacity-10 d-flex align-items-center justify-content-between">
<h6 class="mb-0">
<i class="bi bi-list-ul me-2"></i>Buckets accessible via <strong>@browsedLink.Name</strong>
</h6>
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="CloseBrowseBuckets">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(browseError))
{
<div class="alert alert-danger small py-2">
<i class="bi bi-exclamation-triangle me-1"></i>@browseError
</div>
}
else if (browsedBuckets is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-secondary"></div>
<span class="ms-2 text-muted small">Loading buckets...</span>
</div>
}
else if (browsedBuckets.Count == 0)
{
<p class="text-muted small mb-0">No buckets found under this project.</p>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th class="small">Bucket Name</th>
<th class="small">Created</th>
<th class="small">Registered in EntKube</th>
</tr>
</thead>
<tbody>
@foreach (S3BucketInfo bucket in browsedBuckets)
{
bool registered = storageLinks.Any(l =>
l.Provider == StorageProvider.CleuraS3 &&
l.BucketName == bucket.Name);
<tr>
<td class="small font-monospace">@bucket.Name</td>
<td class="small text-muted">
@(bucket.CreatedAt == default ? "—" : bucket.CreatedAt.ToString("yyyy-MM-dd"))
</td>
<td class="small">
@if (registered)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">Yes</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary border">No</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<p class="text-muted small mt-2 mb-0">
<i class="bi bi-info-circle me-1"></i>
@browsedBuckets.Count bucket(s) accessible. Buckets not registered in EntKube were created outside the platform.
</p>
}
</div>
</div>
}
@* ── Add Storage Link Form ── *@
@if (showForm)
{
@@ -652,6 +741,12 @@ else
private int corsMaxAge = 3600;
private string bucketPolicyJson = "";
// Browse buckets state
private StorageLink? browsedLink;
private Guid? browsingLinkId;
private List<S3BucketInfo>? browsedBuckets;
private string? browseError;
protected override async Task OnInitializedAsync()
{
await LoadData();
@@ -846,6 +941,35 @@ else
manageSuccess = null;
}
private async Task BrowseBuckets(StorageLink link)
{
browsingLinkId = link.Id;
browsedLink = link;
browsedBuckets = null;
browseError = null;
StateHasChanged();
try
{
browsedBuckets = await StorageService.ListCleuraS3BucketsAsync(TenantId, link.Id);
}
catch (Exception ex)
{
browseError = $"Failed to list buckets: {ex.Message}";
}
finally
{
browsingLinkId = null;
}
}
private void CloseBrowseBuckets()
{
browsedLink = null;
browsedBuckets = null;
browseError = null;
}
private async Task LoadCors()
{
if (managedLink is null)

View File

@@ -63,6 +63,11 @@ else
<i class="bi bi-bucket me-1"></i>Storage
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => activeTab = "identity"'>
<i class="bi bi-person-badge me-1"></i>Identity
</button>
</li>
</ul>
<div class="tab-content">
@@ -86,6 +91,9 @@ else
case "storage":
<StorageTab TenantId="tenant.Id" />
break;
case "identity":
<IdentityTab TenantId="tenant.Id" />
break;
}
</div>
}

View File

@@ -86,4 +86,5 @@ public class AppDeployment
public ICollection<DeploymentManifest> Manifests { get; set; } = [];
public ICollection<DeploymentResource> Resources { get; set; } = [];
public ICollection<StorageBinding> StorageBindings { get; set; } = [];
public ICollection<DatabaseBinding> DatabaseBindings { get; set; } = [];
}

View File

@@ -36,6 +36,13 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
public DbSet<CnpgCluster> CnpgClusters => Set<CnpgCluster>();
public DbSet<CnpgDatabase> CnpgDatabases => Set<CnpgDatabase>();
public DbSet<CnpgBackup> CnpgBackups => Set<CnpgBackup>();
public DbSet<MongoCluster> MongoClusters => Set<MongoCluster>();
public DbSet<MongoDatabase> MongoDatabases => Set<MongoDatabase>();
public DbSet<DatabaseBinding> DatabaseBindings => Set<DatabaseBinding>();
public DbSet<MongoBackup> MongoBackups => Set<MongoBackup>();
public DbSet<KeycloakComponentConfig> KeycloakComponentConfigs => Set<KeycloakComponentConfig>();
public DbSet<KeycloakRealm> KeycloakRealms => Set<KeycloakRealm>();
public DbSet<KeycloakBackup> KeycloakBackups => Set<KeycloakBackup>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -265,6 +272,16 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany()
.HasForeignKey(s => s.CnpgDatabaseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(s => s.MongoDatabase)
.WithMany()
.HasForeignKey(s => s.MongoDatabaseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(s => s.MongoCluster)
.WithMany()
.HasForeignKey(s => s.MongoClusterId)
.OnDelete(DeleteBehavior.Cascade);
});
// ClusterComponent — a deployable unit (Helm chart, operator, etc.) on a cluster.
@@ -476,6 +493,151 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.HasForeignKey(b => b.CnpgClusterId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MongoCluster>(entity =>
{
entity.HasKey(c => c.Id);
entity.Property(c => c.Name).HasMaxLength(63).IsRequired();
entity.Property(c => c.Namespace).HasMaxLength(63).IsRequired();
entity.Property(c => c.MongoVersion).HasMaxLength(20).IsRequired();
entity.Property(c => c.StorageSize).HasMaxLength(20).IsRequired();
entity.Property(c => c.BackupSchedule).HasMaxLength(100);
entity.HasIndex(c => new { c.KubernetesClusterId, c.Name, c.Namespace }).IsUnique();
entity.HasOne(c => c.Tenant)
.WithMany()
.HasForeignKey(c => c.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(c => c.KubernetesCluster)
.WithMany()
.HasForeignKey(c => c.KubernetesClusterId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(c => c.StorageLink)
.WithMany()
.HasForeignKey(c => c.StorageLinkId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<MongoDatabase>(entity =>
{
entity.HasKey(d => d.Id);
entity.Property(d => d.Name).HasMaxLength(63).IsRequired();
entity.HasIndex(d => new { d.MongoClusterId, d.Name }).IsUnique();
entity.HasOne(d => d.MongoCluster)
.WithMany(c => c.Databases)
.HasForeignKey(d => d.MongoClusterId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MongoBackup>(entity =>
{
entity.HasKey(b => b.Id);
entity.Property(b => b.Name).HasMaxLength(253).IsRequired();
entity.HasIndex(b => new { b.MongoClusterId, b.Name }).IsUnique();
entity.HasOne(b => b.MongoCluster)
.WithMany(c => c.Backups)
.HasForeignKey(b => b.MongoClusterId)
.OnDelete(DeleteBehavior.Cascade);
});
// KeycloakComponentConfig — links a detected Keycloak ClusterComponent to its
// backing CNPG database. DB credentials and the admin password are stored as
// component vault secrets (ComponentId = ClusterComponentId) and synced to K8s.
builder.Entity<KeycloakComponentConfig>(entity =>
{
entity.HasKey(c => c.Id);
entity.Property(c => c.AdminUsername).HasMaxLength(100).IsRequired();
entity.Property(c => c.AdminUrl).HasMaxLength(500);
entity.HasOne(c => c.Tenant)
.WithMany()
.HasForeignKey(c => c.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(c => c.ClusterComponent)
.WithMany()
.HasForeignKey(c => c.ClusterComponentId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(c => c.CnpgDatabase)
.WithMany()
.HasForeignKey(c => c.CnpgDatabaseId)
.OnDelete(DeleteBehavior.SetNull);
});
// KeycloakRealm — a realm managed via a component config. RealmName must be
// unique within a config (Keycloak enforces this globally per server).
builder.Entity<KeycloakRealm>(entity =>
{
entity.HasKey(r => r.Id);
entity.Property(r => r.RealmName).HasMaxLength(100).IsRequired();
entity.Property(r => r.DisplayName).HasMaxLength(200).IsRequired();
entity.HasIndex(r => new { r.KeycloakComponentConfigId, r.RealmName }).IsUnique();
entity.HasOne(r => r.ComponentConfig)
.WithMany(c => c.Realms)
.HasForeignKey(r => r.KeycloakComponentConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(r => r.LinkedApp)
.WithMany()
.HasForeignKey(r => r.LinkedAppId)
.OnDelete(DeleteBehavior.SetNull);
});
// KeycloakBackup — realm JSON snapshots stored in S3.
builder.Entity<KeycloakBackup>(entity =>
{
entity.HasKey(b => b.Id);
entity.Property(b => b.ObjectKey).HasMaxLength(1024).IsRequired();
entity.Property(b => b.RealmName).HasMaxLength(100).IsRequired();
entity.HasOne(b => b.Realm)
.WithMany(r => r.Backups)
.HasForeignKey(b => b.KeycloakRealmId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(b => b.StorageLink)
.WithMany()
.HasForeignKey(b => b.StorageLinkId)
.OnDelete(DeleteBehavior.Restrict);
});
// DatabaseBinding — connects a managed database (CNPG or MongoDB) to an
// AppDeployment. The platform syncs credentials into the app's namespace
// automatically, including after password rotations.
builder.Entity<DatabaseBinding>(entity =>
{
entity.HasKey(b => b.Id);
entity.Property(b => b.KubernetesSecretName).HasMaxLength(253).IsRequired();
entity.HasOne(b => b.CnpgDatabase)
.WithMany(d => d.DatabaseBindings)
.HasForeignKey(b => b.CnpgDatabaseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(b => b.MongoDatabase)
.WithMany(d => d.DatabaseBindings)
.HasForeignKey(b => b.MongoDatabaseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(b => b.AppDeployment)
.WithMany(d => d.DatabaseBindings)
.HasForeignKey(b => b.AppDeploymentId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace EntKube.Web.Data;
/// <summary>
@@ -69,6 +71,14 @@ public class CnpgBackup
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// The barman-cloud backup identifier (e.g. "20260519T130201").
/// Populated from the Backup CR status.backupId at display time.
/// Not persisted — only available when the backup list comes from live K8s.
/// </summary>
[NotMapped]
public string? BarmanId { get; set; }
// Navigation
public CnpgCluster CnpgCluster { get; set; } = null!;
}

View File

@@ -77,6 +77,12 @@ public class CnpgCluster
/// </summary>
public string? BackupSchedule { get; set; }
/// <summary>
/// Number of days to retain backups. The Barman Cloud Plugin uses this as the
/// recovery window (e.g. "30d"). Expired backups are cleaned up automatically.
/// </summary>
public int RetentionDays { get; set; } = 30;
/// <summary>
/// Current lifecycle status of the cluster.
/// </summary>

View File

@@ -41,6 +41,12 @@ public class CnpgClusterDetail
/// The current write LAG in bytes across replicas (0 = fully caught up).
/// </summary>
public long ReplicationLagBytes { get; set; }
/// <summary>
/// Live-synced backup list reconciled from K8s Backup CRs plus the DB records.
/// Populated by GetClusterDetailAsync; falls back to Cluster.Backups if sync is skipped.
/// </summary>
public List<CnpgBackup> Backups { get; set; } = [];
}
/// <summary>

View File

@@ -51,4 +51,5 @@ public class CnpgDatabase
// Navigation
public CnpgCluster CnpgCluster { get; set; } = null!;
public ICollection<DatabaseBinding> DatabaseBindings { get; set; } = [];
}

View File

@@ -0,0 +1,60 @@
namespace EntKube.Web.Data;
/// <summary>
/// Connects a managed database (CNPG or MongoDB) to an app deployment so the
/// platform can sync credentials into the app's namespace automatically.
///
/// When a binding exists and SyncEnabled is true, any credential sync (including
/// after password rotation) will also push a Kubernetes Secret named
/// KubernetesSecretName into the AppDeployment's namespace on its cluster.
/// The app can then mount the secret as env vars without knowing which database
/// provider backs it.
/// </summary>
public class DatabaseBinding
{
public Guid Id { get; set; }
/// <summary>
/// The CNPG database whose credentials should be synced to the app.
/// Mutually exclusive with MongoDatabaseId.
/// </summary>
public Guid? CnpgDatabaseId { get; set; }
/// <summary>
/// The MongoDB database whose credentials should be synced to the app.
/// Mutually exclusive with CnpgDatabaseId.
/// </summary>
public Guid? MongoDatabaseId { get; set; }
/// <summary>
/// The deployment that consumes the database credentials.
/// The secret is written into this deployment's namespace on its cluster.
/// </summary>
public Guid AppDeploymentId { get; set; }
/// <summary>
/// The Kubernetes Secret name to create in the app's namespace.
/// Choose something meaningful to the app (e.g. "billing-db", "app-postgres").
/// </summary>
public required string KubernetesSecretName { get; set; }
/// <summary>
/// When true, credential syncs (including password rotations) automatically
/// propagate to this app's namespace. Disable to pause propagation without
/// removing the binding record.
/// </summary>
public bool SyncEnabled { get; set; } = true;
/// <summary>
/// Last time credentials were successfully written to the app's namespace.
/// Null means the binding exists but has not been synced yet.
/// </summary>
public DateTime? LastSyncedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public CnpgDatabase? CnpgDatabase { get; set; }
public MongoDatabase? MongoDatabase { get; set; }
public AppDeployment AppDeployment { get; set; } = null!;
}

View File

@@ -0,0 +1,49 @@
namespace EntKube.Web.Data;
public enum KeycloakBackupStatus
{
Creating,
Ready,
Failed
}
/// <summary>
/// A backup of a Keycloak realm — the full realm JSON exported via the Keycloak
/// Admin REST API and stored in an S3 bucket. Used for disaster recovery and
/// migrating realms between environments.
/// </summary>
public class KeycloakBackup
{
public Guid Id { get; set; }
public Guid KeycloakRealmId { get; set; }
public Guid TenantId { get; set; }
public Guid StorageLinkId { get; set; }
/// <summary>
/// S3 object key (e.g. "keycloak-backups/my-realm/2026-05-19T14-32-00.json").
/// </summary>
public required string ObjectKey { get; set; }
/// <summary>
/// Snapshot of the realm name at backup time, for display after the realm
/// may have been renamed or deleted.
/// </summary>
public required string RealmName { get; set; }
public long SizeBytes { get; set; }
public KeycloakBackupStatus Status { get; set; } = KeycloakBackupStatus.Creating;
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
// Navigation
public KeycloakRealm Realm { get; set; } = null!;
public StorageLink StorageLink { get; set; } = null!;
}

View File

@@ -0,0 +1,52 @@
namespace EntKube.Web.Data;
/// <summary>
/// Keycloak-specific configuration attached to an installed Keycloak ClusterComponent.
/// Stores which CNPG database backs this Keycloak instance and the admin credentials
/// needed for the Admin REST API.
///
/// DB credentials (KC_DB_URL, KC_DB_USERNAME, KC_DB_PASSWORD) are stored as
/// component vault secrets and synced to a Kubernetes Secret so the Helm chart
/// can consume them without any out-of-band secret management.
///
/// The admin password is stored as a component vault secret named
/// "KEYCLOAK_ADMIN_PASSWORD" and synced to the same Kubernetes Secret.
/// </summary>
public class KeycloakComponentConfig
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
/// <summary>
/// The installed Keycloak ClusterComponent this config belongs to.
/// One config per Keycloak component instance.
/// </summary>
public Guid ClusterComponentId { get; set; }
/// <summary>
/// The managed CNPG database backing this Keycloak instance.
/// Null until the operator selects a database.
/// </summary>
public Guid? CnpgDatabaseId { get; set; }
/// <summary>
/// Keycloak admin username (default "admin").
/// </summary>
public string AdminUsername { get; set; } = "admin";
/// <summary>
/// URL used by the platform to call the Keycloak Admin REST API.
/// Typically the external hostname (e.g. "https://auth.example.com").
/// Can be auto-suggested from the component's ExternalRoutes.
/// </summary>
public string? AdminUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Tenant Tenant { get; set; } = null!;
public ClusterComponent ClusterComponent { get; set; } = null!;
public CnpgDatabase? CnpgDatabase { get; set; }
public ICollection<KeycloakRealm> Realms { get; set; } = [];
}

View File

@@ -0,0 +1,51 @@
namespace EntKube.Web.Data;
/// <summary>
/// A Keycloak realm managed by EntKube. The realm exists in Keycloak;
/// this entity tracks it so EntKube can surface it in the UI, manage backups,
/// and optionally link it to a customer app for self-service portal access.
/// </summary>
public class KeycloakRealm
{
public Guid Id { get; set; }
public Guid KeycloakComponentConfigId { get; set; }
public Guid TenantId { get; set; }
/// <summary>
/// The Keycloak realm ID (e.g. "acme-prod"). Used in all API calls.
/// </summary>
public required string RealmName { get; set; }
/// <summary>
/// Human-readable display name shown inside the Keycloak login UI.
/// </summary>
public required string DisplayName { get; set; }
public bool Enabled { get; set; } = true;
/// <summary>
/// Login page theme assigned to this realm (e.g. "keycloak", "custom-brand").
/// Null means the Keycloak default.
/// </summary>
public string? LoginTheme { get; set; }
/// <summary>
/// Account console theme for self-service password management.
/// </summary>
public string? AccountTheme { get; set; }
/// <summary>
/// When set, this realm is linked to a customer app and the customer can
/// manage their realm (users, groups, IdPs) through the portal.
/// </summary>
public Guid? LinkedAppId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public KeycloakComponentConfig ComponentConfig { get; set; } = null!;
public App? LinkedApp { get; set; }
public ICollection<KeycloakBackup> Backups { get; set; } = [];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddCnpgRetentionDays : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "RetentionDays",
table: "CnpgClusters",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RetentionDays",
table: "CnpgClusters");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddMongoEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "MongoDatabaseId",
table: "VaultSecrets",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "MongoClusters",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
KubernetesClusterId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
Namespace = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
MongoVersion = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Members = table.Column<int>(type: "integer", nullable: false),
StorageSize = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
StorageLinkId = table.Column<Guid>(type: "uuid", nullable: true),
BackupSchedule = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
RetentionDays = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MongoClusters", x => x.Id);
table.ForeignKey(
name: "FK_MongoClusters_KubernetesClusters_KubernetesClusterId",
column: x => x.KubernetesClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MongoClusters_StorageLinks_StorageLinkId",
column: x => x.StorageLinkId,
principalTable: "StorageLinks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_MongoClusters_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MongoBackups",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MongoClusterId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
SizeBytes = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MongoBackups", x => x.Id);
table.ForeignKey(
name: "FK_MongoBackups_MongoClusters_MongoClusterId",
column: x => x.MongoClusterId,
principalTable: "MongoClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MongoDatabases",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MongoClusterId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
Owner = table.Column<string>(type: "text", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MongoDatabases", x => x.Id);
table.ForeignKey(
name: "FK_MongoDatabases_MongoClusters_MongoClusterId",
column: x => x.MongoClusterId,
principalTable: "MongoClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_MongoDatabaseId",
table: "VaultSecrets",
column: "MongoDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_MongoBackups_MongoClusterId_Name",
table: "MongoBackups",
columns: new[] { "MongoClusterId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MongoClusters_KubernetesClusterId_Name_Namespace",
table: "MongoClusters",
columns: new[] { "KubernetesClusterId", "Name", "Namespace" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MongoClusters_StorageLinkId",
table: "MongoClusters",
column: "StorageLinkId");
migrationBuilder.CreateIndex(
name: "IX_MongoClusters_TenantId",
table: "MongoClusters",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_MongoDatabases_MongoClusterId_Name",
table: "MongoDatabases",
columns: new[] { "MongoClusterId", "Name" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_MongoDatabases_MongoDatabaseId",
table: "VaultSecrets",
column: "MongoDatabaseId",
principalTable: "MongoDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_MongoDatabases_MongoDatabaseId",
table: "VaultSecrets");
migrationBuilder.DropTable(
name: "MongoBackups");
migrationBuilder.DropTable(
name: "MongoDatabases");
migrationBuilder.DropTable(
name: "MongoClusters");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_MongoDatabaseId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "MongoDatabaseId",
table: "VaultSecrets");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddDatabaseBindings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DatabaseBindings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CnpgDatabaseId = table.Column<Guid>(type: "uuid", nullable: true),
MongoDatabaseId = table.Column<Guid>(type: "uuid", nullable: true),
AppDeploymentId = table.Column<Guid>(type: "uuid", nullable: false),
KubernetesSecretName = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
SyncEnabled = table.Column<bool>(type: "boolean", nullable: false),
LastSyncedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DatabaseBindings", x => x.Id);
table.ForeignKey(
name: "FK_DatabaseBindings_AppDeployments_AppDeploymentId",
column: x => x.AppDeploymentId,
principalTable: "AppDeployments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DatabaseBindings_CnpgDatabases_CnpgDatabaseId",
column: x => x.CnpgDatabaseId,
principalTable: "CnpgDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DatabaseBindings_MongoDatabases_MongoDatabaseId",
column: x => x.MongoDatabaseId,
principalTable: "MongoDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_DatabaseBindings_AppDeploymentId",
table: "DatabaseBindings",
column: "AppDeploymentId");
migrationBuilder.CreateIndex(
name: "IX_DatabaseBindings_CnpgDatabaseId",
table: "DatabaseBindings",
column: "CnpgDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_DatabaseBindings_MongoDatabaseId",
table: "DatabaseBindings",
column: "MongoDatabaseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DatabaseBindings");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddMongoClusterVaultSecret : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "MongoClusterId",
table: "VaultSecrets",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_MongoClusterId",
table: "VaultSecrets",
column: "MongoClusterId");
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_MongoClusters_MongoClusterId",
table: "VaultSecrets",
column: "MongoClusterId",
principalTable: "MongoClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_MongoClusters_MongoClusterId",
table: "VaultSecrets");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_MongoClusterId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "MongoClusterId",
table: "VaultSecrets");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddKeycloakComponentConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
table: "KeycloakRealms");
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.DropTable(
name: "KeycloakConnections");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.RenameColumn(
name: "KeycloakConnectionId",
table: "KeycloakRealms",
newName: "KeycloakComponentConfigId");
migrationBuilder.RenameIndex(
name: "IX_KeycloakRealms_KeycloakConnectionId_RealmName",
table: "KeycloakRealms",
newName: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName");
migrationBuilder.CreateTable(
name: "KeycloakComponentConfigs",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
ClusterComponentId = table.Column<Guid>(type: "uuid", nullable: false),
CnpgDatabaseId = table.Column<Guid>(type: "uuid", nullable: true),
AdminUsername = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
AdminUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KeycloakComponentConfigs", x => x.Id);
table.ForeignKey(
name: "FK_KeycloakComponentConfigs_ClusterComponents_ClusterComponent~",
column: x => x.ClusterComponentId,
principalTable: "ClusterComponents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_KeycloakComponentConfigs_CnpgDatabases_CnpgDatabaseId",
column: x => x.CnpgDatabaseId,
principalTable: "CnpgDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_KeycloakComponentConfigs_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_KeycloakComponentConfigs_ClusterComponentId",
table: "KeycloakComponentConfigs",
column: "ClusterComponentId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakComponentConfigs_CnpgDatabaseId",
table: "KeycloakComponentConfigs",
column: "CnpgDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakComponentConfigs_TenantId",
table: "KeycloakComponentConfigs",
column: "TenantId");
migrationBuilder.AddForeignKey(
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~",
table: "KeycloakRealms",
column: "KeycloakComponentConfigId",
principalTable: "KeycloakComponentConfigs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~",
table: "KeycloakRealms");
migrationBuilder.DropTable(
name: "KeycloakComponentConfigs");
migrationBuilder.RenameColumn(
name: "KeycloakComponentConfigId",
table: "KeycloakRealms",
newName: "KeycloakConnectionId");
migrationBuilder.RenameIndex(
name: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName",
table: "KeycloakRealms",
newName: "IX_KeycloakRealms_KeycloakConnectionId_RealmName");
migrationBuilder.AddColumn<Guid>(
name: "KeycloakConnectionId",
table: "VaultSecrets",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "KeycloakConnections",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AdminPasswordSecretId = table.Column<Guid>(type: "uuid", nullable: true),
KubernetesClusterId = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
AdminUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
AdminUsername = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KeycloakConnections", x => x.Id);
table.ForeignKey(
name: "FK_KeycloakConnections_KubernetesClusters_KubernetesClusterId",
column: x => x.KubernetesClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_KeycloakConnections_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_KeycloakConnections_VaultSecrets_AdminPasswordSecretId",
column: x => x.AdminPasswordSecretId,
principalTable: "VaultSecrets",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_KeycloakConnectionId",
table: "VaultSecrets",
column: "KeycloakConnectionId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_AdminPasswordSecretId",
table: "KeycloakConnections",
column: "AdminPasswordSecretId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_KubernetesClusterId",
table: "KeycloakConnections",
column: "KubernetesClusterId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_TenantId",
table: "KeycloakConnections",
column: "TenantId");
migrationBuilder.AddForeignKey(
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
table: "KeycloakRealms",
column: "KeycloakConnectionId",
principalTable: "KeycloakConnections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId",
table: "VaultSecrets",
column: "KeycloakConnectionId",
principalTable: "KeycloakConnections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@@ -342,6 +342,9 @@ namespace EntKube.Web.Data.Migrations.Postgres
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<int>("RetentionDays")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
@@ -449,6 +452,46 @@ namespace EntKube.Web.Data.Migrations.Postgres
b.ToTable("CustomerAccesses");
});
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AppDeploymentId")
.HasColumnType("uuid");
b.Property<Guid?>("CnpgDatabaseId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("KubernetesSecretName")
.IsRequired()
.HasMaxLength(253)
.HasColumnType("character varying(253)");
b.Property<DateTime?>("LastSyncedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MongoDatabaseId")
.HasColumnType("uuid");
b.Property<bool>("SyncEnabled")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("AppDeploymentId");
b.HasIndex("CnpgDatabaseId");
b.HasIndex("MongoDatabaseId");
b.ToTable("DatabaseBindings");
});
modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b =>
{
b.Property<Guid>("Id")
@@ -679,6 +722,140 @@ namespace EntKube.Web.Data.Migrations.Postgres
b.ToTable("GroupMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("KeycloakRealmId")
.HasColumnType("uuid");
b.Property<string>("LastError")
.HasColumnType("text");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("RealmName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("StorageLinkId")
.HasColumnType("uuid");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("KeycloakRealmId");
b.HasIndex("StorageLinkId");
b.ToTable("KeycloakBackups");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AdminUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("AdminUsername")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("ClusterComponentId")
.HasColumnType("uuid");
b.Property<Guid?>("CnpgDatabaseId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClusterComponentId");
b.HasIndex("CnpgDatabaseId");
b.HasIndex("TenantId");
b.ToTable("KeycloakComponentConfigs");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakRealm", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccountTheme")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<Guid>("KeycloakComponentConfigId")
.HasColumnType("uuid");
b.Property<Guid?>("LinkedAppId")
.HasColumnType("uuid");
b.Property<string>("LoginTheme")
.HasColumnType("text");
b.Property<string>("RealmName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("LinkedAppId");
b.HasIndex("KeycloakComponentConfigId", "RealmName")
.IsUnique();
b.ToTable("KeycloakRealms");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Property<Guid>("Id")
@@ -720,6 +897,141 @@ namespace EntKube.Web.Data.Migrations.Postgres
b.ToTable("KubernetesClusters");
});
modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("MongoClusterId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(253)
.HasColumnType("character varying(253)");
b.Property<long?>("SizeBytes")
.HasColumnType("bigint");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("MongoClusterId", "Name")
.IsUnique();
b.ToTable("MongoBackups");
});
modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BackupSchedule")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("KubernetesClusterId")
.HasColumnType("uuid");
b.Property<string>("LastError")
.HasColumnType("text");
b.Property<int>("Members")
.HasColumnType("integer");
b.Property<string>("MongoVersion")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("character varying(63)");
b.Property<string>("Namespace")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("character varying(63)");
b.Property<int>("RetentionDays")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid?>("StorageLinkId")
.HasColumnType("uuid");
b.Property<string>("StorageSize")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("StorageLinkId");
b.HasIndex("TenantId");
b.HasIndex("KubernetesClusterId", "Name", "Namespace")
.IsUnique();
b.ToTable("MongoClusters");
});
modelBuilder.Entity("EntKube.Web.Data.MongoDatabase", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("MongoClusterId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("character varying(63)");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("MongoClusterId", "Name")
.IsUnique();
b.ToTable("MongoDatabases");
});
modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b =>
{
b.Property<Guid>("Id")
@@ -988,6 +1300,12 @@ namespace EntKube.Web.Data.Migrations.Postgres
.HasMaxLength(253)
.HasColumnType("character varying(253)");
b.Property<Guid?>("MongoClusterId")
.HasColumnType("uuid");
b.Property<Guid?>("MongoDatabaseId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -1020,6 +1338,10 @@ namespace EntKube.Web.Data.Migrations.Postgres
b.HasIndex("ComponentId");
b.HasIndex("MongoClusterId");
b.HasIndex("MongoDatabaseId");
b.HasIndex("StorageLinkId");
b.HasIndex("VaultId", "AppId", "Name")
@@ -1309,6 +1631,31 @@ namespace EntKube.Web.Data.Migrations.Postgres
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
{
b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment")
.WithMany("DatabaseBindings")
.HasForeignKey("AppDeploymentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase")
.WithMany("DatabaseBindings")
.HasForeignKey("CnpgDatabaseId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.MongoDatabase", "MongoDatabase")
.WithMany("DatabaseBindings")
.HasForeignKey("MongoDatabaseId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("AppDeployment");
b.Navigation("CnpgDatabase");
b.Navigation("MongoDatabase");
});
modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b =>
{
b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment")
@@ -1390,6 +1737,69 @@ namespace EntKube.Web.Data.Migrations.Postgres
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b =>
{
b.HasOne("EntKube.Web.Data.KeycloakRealm", "Realm")
.WithMany("Backups")
.HasForeignKey("KeycloakRealmId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink")
.WithMany()
.HasForeignKey("StorageLinkId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Realm");
b.Navigation("StorageLink");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b =>
{
b.HasOne("EntKube.Web.Data.ClusterComponent", "ClusterComponent")
.WithMany()
.HasForeignKey("ClusterComponentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase")
.WithMany()
.HasForeignKey("CnpgDatabaseId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ClusterComponent");
b.Navigation("CnpgDatabase");
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakRealm", b =>
{
b.HasOne("EntKube.Web.Data.KeycloakComponentConfig", "ComponentConfig")
.WithMany("Realms")
.HasForeignKey("KeycloakComponentConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.App", "LinkedApp")
.WithMany()
.HasForeignKey("LinkedAppId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ComponentConfig");
b.Navigation("LinkedApp");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.HasOne("EntKube.Web.Data.Environment", "Environment")
@@ -1409,6 +1819,54 @@ namespace EntKube.Web.Data.Migrations.Postgres
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b =>
{
b.HasOne("EntKube.Web.Data.MongoCluster", "MongoCluster")
.WithMany("Backups")
.HasForeignKey("MongoClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MongoCluster");
});
modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b =>
{
b.HasOne("EntKube.Web.Data.KubernetesCluster", "KubernetesCluster")
.WithMany()
.HasForeignKey("KubernetesClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink")
.WithMany()
.HasForeignKey("StorageLinkId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("KubernetesCluster");
b.Navigation("StorageLink");
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.MongoDatabase", b =>
{
b.HasOne("EntKube.Web.Data.MongoCluster", "MongoCluster")
.WithMany("Databases")
.HasForeignKey("MongoClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MongoCluster");
});
modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
@@ -1542,6 +2000,16 @@ namespace EntKube.Web.Data.Migrations.Postgres
.HasForeignKey("ComponentId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.MongoCluster", "MongoCluster")
.WithMany()
.HasForeignKey("MongoClusterId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.MongoDatabase", "MongoDatabase")
.WithMany()
.HasForeignKey("MongoDatabaseId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink")
.WithMany()
.HasForeignKey("StorageLinkId");
@@ -1558,6 +2026,10 @@ namespace EntKube.Web.Data.Migrations.Postgres
b.Navigation("Component");
b.Navigation("MongoCluster");
b.Navigation("MongoDatabase");
b.Navigation("StorageLink");
b.Navigation("Vault");
@@ -1625,6 +2097,8 @@ namespace EntKube.Web.Data.Migrations.Postgres
modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b =>
{
b.Navigation("DatabaseBindings");
b.Navigation("Manifests");
b.Navigation("Resources");
@@ -1648,6 +2122,11 @@ namespace EntKube.Web.Data.Migrations.Postgres
b.Navigation("Databases");
});
modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b =>
{
b.Navigation("DatabaseBindings");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Navigation("Apps");
@@ -1670,11 +2149,33 @@ namespace EntKube.Web.Data.Migrations.Postgres
b.Navigation("Memberships");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b =>
{
b.Navigation("Realms");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakRealm", b =>
{
b.Navigation("Backups");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Navigation("Components");
});
modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b =>
{
b.Navigation("Backups");
b.Navigation("Databases");
});
modelBuilder.Entity("EntKube.Web.Data.MongoDatabase", b =>
{
b.Navigation("DatabaseBindings");
});
modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b =>
{
b.Navigation("StorageLinks");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.SqlServer
{
/// <inheritdoc />
public partial class AddCnpgRetentionDays : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CnpgDatabaseId",
table: "VaultSecrets",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "CnpgClusters",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
KubernetesClusterId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(63)", maxLength: 63, nullable: false),
Namespace = table.Column<string>(type: "nvarchar(63)", maxLength: 63, nullable: false),
PostgresVersion = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
Instances = table.Column<int>(type: "int", nullable: false),
StorageSize = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
StorageLinkId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
BackupSchedule = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
RetentionDays = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
LastError = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CnpgClusters", x => x.Id);
table.ForeignKey(
name: "FK_CnpgClusters_KubernetesClusters_KubernetesClusterId",
column: x => x.KubernetesClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CnpgClusters_StorageLinks_StorageLinkId",
column: x => x.StorageLinkId,
principalTable: "StorageLinks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_CnpgClusters_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CnpgBackups",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CnpgClusterId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(253)", maxLength: 253, nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
StartedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
SizeBytes = table.Column<long>(type: "bigint", nullable: true),
LastError = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CnpgBackups", x => x.Id);
table.ForeignKey(
name: "FK_CnpgBackups_CnpgClusters_CnpgClusterId",
column: x => x.CnpgClusterId,
principalTable: "CnpgClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CnpgDatabases",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CnpgClusterId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(63)", maxLength: 63, nullable: false),
Owner = table.Column<string>(type: "nvarchar(63)", maxLength: 63, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CnpgDatabases", x => x.Id);
table.ForeignKey(
name: "FK_CnpgDatabases_CnpgClusters_CnpgClusterId",
column: x => x.CnpgClusterId,
principalTable: "CnpgClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_CnpgDatabaseId",
table: "VaultSecrets",
column: "CnpgDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_CnpgBackups_CnpgClusterId_Name",
table: "CnpgBackups",
columns: new[] { "CnpgClusterId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CnpgClusters_KubernetesClusterId_Name_Namespace",
table: "CnpgClusters",
columns: new[] { "KubernetesClusterId", "Name", "Namespace" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CnpgClusters_StorageLinkId",
table: "CnpgClusters",
column: "StorageLinkId");
migrationBuilder.CreateIndex(
name: "IX_CnpgClusters_TenantId",
table: "CnpgClusters",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_CnpgDatabases_CnpgClusterId_Name",
table: "CnpgDatabases",
columns: new[] { "CnpgClusterId", "Name" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_CnpgDatabases_CnpgDatabaseId",
table: "VaultSecrets",
column: "CnpgDatabaseId",
principalTable: "CnpgDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_CnpgDatabases_CnpgDatabaseId",
table: "VaultSecrets");
migrationBuilder.DropTable(
name: "CnpgBackups");
migrationBuilder.DropTable(
name: "CnpgDatabases");
migrationBuilder.DropTable(
name: "CnpgClusters");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_CnpgDatabaseId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "CnpgDatabaseId",
table: "VaultSecrets");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.SqlServer
{
/// <inheritdoc />
public partial class AddMongoEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "MongoDatabaseId",
table: "VaultSecrets",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "MongoClusters",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
KubernetesClusterId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(63)", maxLength: 63, nullable: false),
Namespace = table.Column<string>(type: "nvarchar(63)", maxLength: 63, nullable: false),
MongoVersion = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Members = table.Column<int>(type: "int", nullable: false),
StorageSize = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
StorageLinkId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
BackupSchedule = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
RetentionDays = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
LastError = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MongoClusters", x => x.Id);
table.ForeignKey(
name: "FK_MongoClusters_KubernetesClusters_KubernetesClusterId",
column: x => x.KubernetesClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MongoClusters_StorageLinks_StorageLinkId",
column: x => x.StorageLinkId,
principalTable: "StorageLinks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_MongoClusters_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MongoBackups",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MongoClusterId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(253)", maxLength: 253, nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
StartedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
SizeBytes = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MongoBackups", x => x.Id);
table.ForeignKey(
name: "FK_MongoBackups_MongoClusters_MongoClusterId",
column: x => x.MongoClusterId,
principalTable: "MongoClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MongoDatabases",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MongoClusterId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(63)", maxLength: 63, nullable: false),
Owner = table.Column<string>(type: "nvarchar(max)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MongoDatabases", x => x.Id);
table.ForeignKey(
name: "FK_MongoDatabases_MongoClusters_MongoClusterId",
column: x => x.MongoClusterId,
principalTable: "MongoClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_MongoDatabaseId",
table: "VaultSecrets",
column: "MongoDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_MongoBackups_MongoClusterId_Name",
table: "MongoBackups",
columns: new[] { "MongoClusterId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MongoClusters_KubernetesClusterId_Name_Namespace",
table: "MongoClusters",
columns: new[] { "KubernetesClusterId", "Name", "Namespace" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MongoClusters_StorageLinkId",
table: "MongoClusters",
column: "StorageLinkId");
migrationBuilder.CreateIndex(
name: "IX_MongoClusters_TenantId",
table: "MongoClusters",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_MongoDatabases_MongoClusterId_Name",
table: "MongoDatabases",
columns: new[] { "MongoClusterId", "Name" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_MongoDatabases_MongoDatabaseId",
table: "VaultSecrets",
column: "MongoDatabaseId",
principalTable: "MongoDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_MongoDatabases_MongoDatabaseId",
table: "VaultSecrets");
migrationBuilder.DropTable(
name: "MongoBackups");
migrationBuilder.DropTable(
name: "MongoDatabases");
migrationBuilder.DropTable(
name: "MongoClusters");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_MongoDatabaseId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "MongoDatabaseId",
table: "VaultSecrets");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.SqlServer
{
/// <inheritdoc />
public partial class AddDatabaseBindings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DatabaseBindings",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CnpgDatabaseId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
MongoDatabaseId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
AppDeploymentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
KubernetesSecretName = table.Column<string>(type: "nvarchar(253)", maxLength: 253, nullable: false),
SyncEnabled = table.Column<bool>(type: "bit", nullable: false),
LastSyncedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DatabaseBindings", x => x.Id);
table.ForeignKey(
name: "FK_DatabaseBindings_AppDeployments_AppDeploymentId",
column: x => x.AppDeploymentId,
principalTable: "AppDeployments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DatabaseBindings_CnpgDatabases_CnpgDatabaseId",
column: x => x.CnpgDatabaseId,
principalTable: "CnpgDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DatabaseBindings_MongoDatabases_MongoDatabaseId",
column: x => x.MongoDatabaseId,
principalTable: "MongoDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_DatabaseBindings_AppDeploymentId",
table: "DatabaseBindings",
column: "AppDeploymentId");
migrationBuilder.CreateIndex(
name: "IX_DatabaseBindings_CnpgDatabaseId",
table: "DatabaseBindings",
column: "CnpgDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_DatabaseBindings_MongoDatabaseId",
table: "DatabaseBindings",
column: "MongoDatabaseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DatabaseBindings");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.SqlServer
{
/// <inheritdoc />
public partial class AddMongoClusterVaultSecret : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "MongoClusterId",
table: "VaultSecrets",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_MongoClusterId",
table: "VaultSecrets",
column: "MongoClusterId");
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_MongoClusters_MongoClusterId",
table: "VaultSecrets",
column: "MongoClusterId",
principalTable: "MongoClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_MongoClusters_MongoClusterId",
table: "VaultSecrets");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_MongoClusterId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "MongoClusterId",
table: "VaultSecrets");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.SqlServer
{
/// <inheritdoc />
public partial class AddKeycloakComponentConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
table: "KeycloakRealms");
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.DropTable(
name: "KeycloakConnections");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.RenameColumn(
name: "KeycloakConnectionId",
table: "KeycloakRealms",
newName: "KeycloakComponentConfigId");
migrationBuilder.RenameIndex(
name: "IX_KeycloakRealms_KeycloakConnectionId_RealmName",
table: "KeycloakRealms",
newName: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName");
migrationBuilder.CreateTable(
name: "KeycloakComponentConfigs",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClusterComponentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CnpgDatabaseId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
AdminUsername = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
AdminUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KeycloakComponentConfigs", x => x.Id);
table.ForeignKey(
name: "FK_KeycloakComponentConfigs_ClusterComponents_ClusterComponentId",
column: x => x.ClusterComponentId,
principalTable: "ClusterComponents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_KeycloakComponentConfigs_CnpgDatabases_CnpgDatabaseId",
column: x => x.CnpgDatabaseId,
principalTable: "CnpgDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_KeycloakComponentConfigs_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_KeycloakComponentConfigs_ClusterComponentId",
table: "KeycloakComponentConfigs",
column: "ClusterComponentId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakComponentConfigs_CnpgDatabaseId",
table: "KeycloakComponentConfigs",
column: "CnpgDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakComponentConfigs_TenantId",
table: "KeycloakComponentConfigs",
column: "TenantId");
migrationBuilder.AddForeignKey(
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentConfigId",
table: "KeycloakRealms",
column: "KeycloakComponentConfigId",
principalTable: "KeycloakComponentConfigs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentConfigId",
table: "KeycloakRealms");
migrationBuilder.DropTable(
name: "KeycloakComponentConfigs");
migrationBuilder.RenameColumn(
name: "KeycloakComponentConfigId",
table: "KeycloakRealms",
newName: "KeycloakConnectionId");
migrationBuilder.RenameIndex(
name: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName",
table: "KeycloakRealms",
newName: "IX_KeycloakRealms_KeycloakConnectionId_RealmName");
migrationBuilder.AddColumn<Guid>(
name: "KeycloakConnectionId",
table: "VaultSecrets",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "KeycloakConnections",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
AdminPasswordSecretId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
KubernetesClusterId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
AdminUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
AdminUsername = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KeycloakConnections", x => x.Id);
table.ForeignKey(
name: "FK_KeycloakConnections_KubernetesClusters_KubernetesClusterId",
column: x => x.KubernetesClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_KeycloakConnections_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_KeycloakConnections_VaultSecrets_AdminPasswordSecretId",
column: x => x.AdminPasswordSecretId,
principalTable: "VaultSecrets",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_KeycloakConnectionId",
table: "VaultSecrets",
column: "KeycloakConnectionId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_AdminPasswordSecretId",
table: "KeycloakConnections",
column: "AdminPasswordSecretId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_KubernetesClusterId",
table: "KeycloakConnections",
column: "KubernetesClusterId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_TenantId",
table: "KeycloakConnections",
column: "TenantId");
migrationBuilder.AddForeignKey(
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
table: "KeycloakRealms",
column: "KeycloakConnectionId",
principalTable: "KeycloakConnections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId",
table: "VaultSecrets",
column: "KeycloakConnectionId",
principalTable: "KeycloakConnections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@@ -266,6 +266,145 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.ToTable("ClusterComponents");
});
modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CnpgClusterId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<string>("LastError")
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(253)
.HasColumnType("nvarchar(253)");
b.Property<long?>("SizeBytes")
.HasColumnType("bigint");
b.Property<DateTime>("StartedAt")
.HasColumnType("datetime2");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CnpgClusterId", "Name")
.IsUnique();
b.ToTable("CnpgBackups");
});
modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BackupSchedule")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<int>("Instances")
.HasColumnType("int");
b.Property<Guid>("KubernetesClusterId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LastError")
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("nvarchar(63)");
b.Property<string>("Namespace")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("nvarchar(63)");
b.Property<string>("PostgresVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<int>("RetentionDays")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<Guid?>("StorageLinkId")
.HasColumnType("uniqueidentifier");
b.Property<string>("StorageSize")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<Guid>("TenantId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("StorageLinkId");
b.HasIndex("TenantId");
b.HasIndex("KubernetesClusterId", "Name", "Namespace")
.IsUnique();
b.ToTable("CnpgClusters");
});
modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CnpgClusterId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("nvarchar(63)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("nvarchar(63)");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CnpgClusterId", "Name")
.IsUnique();
b.ToTable("CnpgDatabases");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Property<Guid>("Id")
@@ -314,6 +453,46 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.ToTable("CustomerAccesses");
});
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("AppDeploymentId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("CnpgDatabaseId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("KubernetesSecretName")
.IsRequired()
.HasMaxLength(253)
.HasColumnType("nvarchar(253)");
b.Property<DateTime?>("LastSyncedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("MongoDatabaseId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("SyncEnabled")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("AppDeploymentId");
b.HasIndex("CnpgDatabaseId");
b.HasIndex("MongoDatabaseId");
b.ToTable("DatabaseBindings");
});
modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b =>
{
b.Property<Guid>("Id")
@@ -544,6 +723,140 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.ToTable("GroupMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid>("KeycloakRealmId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LastError")
.HasColumnType("nvarchar(max)");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("RealmName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<Guid>("StorageLinkId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("TenantId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("KeycloakRealmId");
b.HasIndex("StorageLinkId");
b.ToTable("KeycloakBackups");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AdminUrl")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("AdminUsername")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("ClusterComponentId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("CnpgDatabaseId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid>("TenantId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ClusterComponentId");
b.HasIndex("CnpgDatabaseId");
b.HasIndex("TenantId");
b.ToTable("KeycloakComponentConfigs");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakRealm", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AccountTheme")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<Guid>("KeycloakComponentConfigId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("LinkedAppId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginTheme")
.HasColumnType("nvarchar(max)");
b.Property<string>("RealmName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("LinkedAppId");
b.HasIndex("KeycloakComponentConfigId", "RealmName")
.IsUnique();
b.ToTable("KeycloakRealms");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Property<Guid>("Id")
@@ -585,6 +898,141 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.ToTable("KubernetesClusters");
});
modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<Guid>("MongoClusterId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(253)
.HasColumnType("nvarchar(253)");
b.Property<long?>("SizeBytes")
.HasColumnType("bigint");
b.Property<DateTime>("StartedAt")
.HasColumnType("datetime2");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MongoClusterId", "Name")
.IsUnique();
b.ToTable("MongoBackups");
});
modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BackupSchedule")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid>("KubernetesClusterId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LastError")
.HasColumnType("nvarchar(max)");
b.Property<int>("Members")
.HasColumnType("int");
b.Property<string>("MongoVersion")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("nvarchar(63)");
b.Property<string>("Namespace")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("nvarchar(63)");
b.Property<int>("RetentionDays")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<Guid?>("StorageLinkId")
.HasColumnType("uniqueidentifier");
b.Property<string>("StorageSize")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<Guid>("TenantId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("StorageLinkId");
b.HasIndex("TenantId");
b.HasIndex("KubernetesClusterId", "Name", "Namespace")
.IsUnique();
b.ToTable("MongoClusters");
});
modelBuilder.Entity("EntKube.Web.Data.MongoDatabase", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid>("MongoClusterId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("nvarchar(63)");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MongoClusterId", "Name")
.IsUnique();
b.ToTable("MongoDatabases");
});
modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b =>
{
b.Property<Guid>("Id")
@@ -832,6 +1280,9 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.Property<Guid?>("AppId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("CnpgDatabaseId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ComponentId")
.HasColumnType("uniqueidentifier");
@@ -850,6 +1301,12 @@ namespace EntKube.Web.Data.Migrations.SqlServer
.HasMaxLength(253)
.HasColumnType("nvarchar(253)");
b.Property<Guid?>("MongoClusterId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("MongoDatabaseId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -878,8 +1335,14 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.HasIndex("AppId");
b.HasIndex("CnpgDatabaseId");
b.HasIndex("ComponentId");
b.HasIndex("MongoClusterId");
b.HasIndex("MongoDatabaseId");
b.HasIndex("StorageLinkId");
b.HasIndex("VaultId", "AppId", "Name")
@@ -1092,6 +1555,54 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.Navigation("Cluster");
});
modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b =>
{
b.HasOne("EntKube.Web.Data.CnpgCluster", "CnpgCluster")
.WithMany("Backups")
.HasForeignKey("CnpgClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CnpgCluster");
});
modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b =>
{
b.HasOne("EntKube.Web.Data.KubernetesCluster", "KubernetesCluster")
.WithMany()
.HasForeignKey("KubernetesClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink")
.WithMany()
.HasForeignKey("StorageLinkId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("KubernetesCluster");
b.Navigation("StorageLink");
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b =>
{
b.HasOne("EntKube.Web.Data.CnpgCluster", "CnpgCluster")
.WithMany("Databases")
.HasForeignKey("CnpgClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CnpgCluster");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
@@ -1122,6 +1633,31 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
{
b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment")
.WithMany("DatabaseBindings")
.HasForeignKey("AppDeploymentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase")
.WithMany("DatabaseBindings")
.HasForeignKey("CnpgDatabaseId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.MongoDatabase", "MongoDatabase")
.WithMany("DatabaseBindings")
.HasForeignKey("MongoDatabaseId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("AppDeployment");
b.Navigation("CnpgDatabase");
b.Navigation("MongoDatabase");
});
modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b =>
{
b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment")
@@ -1203,6 +1739,69 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b =>
{
b.HasOne("EntKube.Web.Data.KeycloakRealm", "Realm")
.WithMany("Backups")
.HasForeignKey("KeycloakRealmId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink")
.WithMany()
.HasForeignKey("StorageLinkId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Realm");
b.Navigation("StorageLink");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b =>
{
b.HasOne("EntKube.Web.Data.ClusterComponent", "ClusterComponent")
.WithMany()
.HasForeignKey("ClusterComponentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase")
.WithMany()
.HasForeignKey("CnpgDatabaseId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ClusterComponent");
b.Navigation("CnpgDatabase");
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakRealm", b =>
{
b.HasOne("EntKube.Web.Data.KeycloakComponentConfig", "ComponentConfig")
.WithMany("Realms")
.HasForeignKey("KeycloakComponentConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.App", "LinkedApp")
.WithMany()
.HasForeignKey("LinkedAppId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ComponentConfig");
b.Navigation("LinkedApp");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.HasOne("EntKube.Web.Data.Environment", "Environment")
@@ -1222,6 +1821,54 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b =>
{
b.HasOne("EntKube.Web.Data.MongoCluster", "MongoCluster")
.WithMany("Backups")
.HasForeignKey("MongoClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MongoCluster");
});
modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b =>
{
b.HasOne("EntKube.Web.Data.KubernetesCluster", "KubernetesCluster")
.WithMany()
.HasForeignKey("KubernetesClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink")
.WithMany()
.HasForeignKey("StorageLinkId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("KubernetesCluster");
b.Navigation("StorageLink");
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.MongoDatabase", b =>
{
b.HasOne("EntKube.Web.Data.MongoCluster", "MongoCluster")
.WithMany("Databases")
.HasForeignKey("MongoClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MongoCluster");
});
modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
@@ -1345,11 +1992,26 @@ namespace EntKube.Web.Data.Migrations.SqlServer
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase")
.WithMany()
.HasForeignKey("CnpgDatabaseId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.ClusterComponent", "Component")
.WithMany("Secrets")
.HasForeignKey("ComponentId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.MongoCluster", "MongoCluster")
.WithMany()
.HasForeignKey("MongoClusterId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.MongoDatabase", "MongoDatabase")
.WithMany()
.HasForeignKey("MongoDatabaseId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink")
.WithMany()
.HasForeignKey("StorageLinkId");
@@ -1362,8 +2024,14 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.Navigation("App");
b.Navigation("CnpgDatabase");
b.Navigation("Component");
b.Navigation("MongoCluster");
b.Navigation("MongoDatabase");
b.Navigation("StorageLink");
b.Navigation("Vault");
@@ -1431,6 +2099,8 @@ namespace EntKube.Web.Data.Migrations.SqlServer
modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b =>
{
b.Navigation("DatabaseBindings");
b.Navigation("Manifests");
b.Navigation("Resources");
@@ -1447,6 +2117,18 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.Navigation("StorageBindings");
});
modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b =>
{
b.Navigation("Backups");
b.Navigation("Databases");
});
modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b =>
{
b.Navigation("DatabaseBindings");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Navigation("Apps");
@@ -1469,11 +2151,33 @@ namespace EntKube.Web.Data.Migrations.SqlServer
b.Navigation("Memberships");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b =>
{
b.Navigation("Realms");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakRealm", b =>
{
b.Navigation("Backups");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Navigation("Components");
});
modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b =>
{
b.Navigation("Backups");
b.Navigation("Databases");
});
modelBuilder.Entity("EntKube.Web.Data.MongoDatabase", b =>
{
b.Navigation("DatabaseBindings");
});
modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b =>
{
b.Navigation("StorageLinks");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Sqlite
{
/// <inheritdoc />
public partial class AddCnpgRetentionDays : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "RetentionDays",
table: "CnpgClusters",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RetentionDays",
table: "CnpgClusters");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Sqlite
{
/// <inheritdoc />
public partial class AddMongoEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "MongoDatabaseId",
table: "VaultSecrets",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "MongoClusters",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
TenantId = table.Column<Guid>(type: "TEXT", nullable: false),
KubernetesClusterId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 63, nullable: false),
Namespace = table.Column<string>(type: "TEXT", maxLength: 63, nullable: false),
MongoVersion = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
Members = table.Column<int>(type: "INTEGER", nullable: false),
StorageSize = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
StorageLinkId = table.Column<Guid>(type: "TEXT", nullable: true),
BackupSchedule = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
RetentionDays = table.Column<int>(type: "INTEGER", nullable: false),
Status = table.Column<int>(type: "INTEGER", nullable: false),
LastError = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MongoClusters", x => x.Id);
table.ForeignKey(
name: "FK_MongoClusters_KubernetesClusters_KubernetesClusterId",
column: x => x.KubernetesClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MongoClusters_StorageLinks_StorageLinkId",
column: x => x.StorageLinkId,
principalTable: "StorageLinks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_MongoClusters_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MongoBackups",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
MongoClusterId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 253, nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Status = table.Column<int>(type: "INTEGER", nullable: false),
StartedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
CompletedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
SizeBytes = table.Column<long>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MongoBackups", x => x.Id);
table.ForeignKey(
name: "FK_MongoBackups_MongoClusters_MongoClusterId",
column: x => x.MongoClusterId,
principalTable: "MongoClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MongoDatabases",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
MongoClusterId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 63, nullable: false),
Owner = table.Column<string>(type: "TEXT", nullable: false),
Status = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MongoDatabases", x => x.Id);
table.ForeignKey(
name: "FK_MongoDatabases_MongoClusters_MongoClusterId",
column: x => x.MongoClusterId,
principalTable: "MongoClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_MongoDatabaseId",
table: "VaultSecrets",
column: "MongoDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_MongoBackups_MongoClusterId_Name",
table: "MongoBackups",
columns: new[] { "MongoClusterId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MongoClusters_KubernetesClusterId_Name_Namespace",
table: "MongoClusters",
columns: new[] { "KubernetesClusterId", "Name", "Namespace" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MongoClusters_StorageLinkId",
table: "MongoClusters",
column: "StorageLinkId");
migrationBuilder.CreateIndex(
name: "IX_MongoClusters_TenantId",
table: "MongoClusters",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_MongoDatabases_MongoClusterId_Name",
table: "MongoDatabases",
columns: new[] { "MongoClusterId", "Name" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_MongoDatabases_MongoDatabaseId",
table: "VaultSecrets",
column: "MongoDatabaseId",
principalTable: "MongoDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_MongoDatabases_MongoDatabaseId",
table: "VaultSecrets");
migrationBuilder.DropTable(
name: "MongoBackups");
migrationBuilder.DropTable(
name: "MongoDatabases");
migrationBuilder.DropTable(
name: "MongoClusters");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_MongoDatabaseId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "MongoDatabaseId",
table: "VaultSecrets");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Sqlite
{
/// <inheritdoc />
public partial class AddDatabaseBindings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DatabaseBindings",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
CnpgDatabaseId = table.Column<Guid>(type: "TEXT", nullable: true),
MongoDatabaseId = table.Column<Guid>(type: "TEXT", nullable: true),
AppDeploymentId = table.Column<Guid>(type: "TEXT", nullable: false),
KubernetesSecretName = table.Column<string>(type: "TEXT", maxLength: 253, nullable: false),
SyncEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LastSyncedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DatabaseBindings", x => x.Id);
table.ForeignKey(
name: "FK_DatabaseBindings_AppDeployments_AppDeploymentId",
column: x => x.AppDeploymentId,
principalTable: "AppDeployments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DatabaseBindings_CnpgDatabases_CnpgDatabaseId",
column: x => x.CnpgDatabaseId,
principalTable: "CnpgDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DatabaseBindings_MongoDatabases_MongoDatabaseId",
column: x => x.MongoDatabaseId,
principalTable: "MongoDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_DatabaseBindings_AppDeploymentId",
table: "DatabaseBindings",
column: "AppDeploymentId");
migrationBuilder.CreateIndex(
name: "IX_DatabaseBindings_CnpgDatabaseId",
table: "DatabaseBindings",
column: "CnpgDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_DatabaseBindings_MongoDatabaseId",
table: "DatabaseBindings",
column: "MongoDatabaseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DatabaseBindings");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Sqlite
{
/// <inheritdoc />
public partial class AddMongoClusterVaultSecret : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "MongoClusterId",
table: "VaultSecrets",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_MongoClusterId",
table: "VaultSecrets",
column: "MongoClusterId");
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_MongoClusters_MongoClusterId",
table: "VaultSecrets",
column: "MongoClusterId",
principalTable: "MongoClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_MongoClusters_MongoClusterId",
table: "VaultSecrets");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_MongoClusterId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "MongoClusterId",
table: "VaultSecrets");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Sqlite
{
/// <inheritdoc />
public partial class AddKeycloakComponentConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
table: "KeycloakRealms");
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.DropTable(
name: "KeycloakConnections");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.RenameColumn(
name: "KeycloakConnectionId",
table: "KeycloakRealms",
newName: "KeycloakComponentConfigId");
migrationBuilder.RenameIndex(
name: "IX_KeycloakRealms_KeycloakConnectionId_RealmName",
table: "KeycloakRealms",
newName: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName");
migrationBuilder.CreateTable(
name: "KeycloakComponentConfigs",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
TenantId = table.Column<Guid>(type: "TEXT", nullable: false),
ClusterComponentId = table.Column<Guid>(type: "TEXT", nullable: false),
CnpgDatabaseId = table.Column<Guid>(type: "TEXT", nullable: true),
AdminUsername = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
AdminUrl = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KeycloakComponentConfigs", x => x.Id);
table.ForeignKey(
name: "FK_KeycloakComponentConfigs_ClusterComponents_ClusterComponentId",
column: x => x.ClusterComponentId,
principalTable: "ClusterComponents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_KeycloakComponentConfigs_CnpgDatabases_CnpgDatabaseId",
column: x => x.CnpgDatabaseId,
principalTable: "CnpgDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_KeycloakComponentConfigs_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_KeycloakComponentConfigs_ClusterComponentId",
table: "KeycloakComponentConfigs",
column: "ClusterComponentId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakComponentConfigs_CnpgDatabaseId",
table: "KeycloakComponentConfigs",
column: "CnpgDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakComponentConfigs_TenantId",
table: "KeycloakComponentConfigs",
column: "TenantId");
migrationBuilder.AddForeignKey(
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentConfigId",
table: "KeycloakRealms",
column: "KeycloakComponentConfigId",
principalTable: "KeycloakComponentConfigs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentConfigId",
table: "KeycloakRealms");
migrationBuilder.DropTable(
name: "KeycloakComponentConfigs");
migrationBuilder.RenameColumn(
name: "KeycloakComponentConfigId",
table: "KeycloakRealms",
newName: "KeycloakConnectionId");
migrationBuilder.RenameIndex(
name: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName",
table: "KeycloakRealms",
newName: "IX_KeycloakRealms_KeycloakConnectionId_RealmName");
migrationBuilder.AddColumn<Guid>(
name: "KeycloakConnectionId",
table: "VaultSecrets",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "KeycloakConnections",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
AdminPasswordSecretId = table.Column<Guid>(type: "TEXT", nullable: true),
KubernetesClusterId = table.Column<Guid>(type: "TEXT", nullable: false),
TenantId = table.Column<Guid>(type: "TEXT", nullable: false),
AdminUrl = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
AdminUsername = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KeycloakConnections", x => x.Id);
table.ForeignKey(
name: "FK_KeycloakConnections_KubernetesClusters_KubernetesClusterId",
column: x => x.KubernetesClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_KeycloakConnections_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_KeycloakConnections_VaultSecrets_AdminPasswordSecretId",
column: x => x.AdminPasswordSecretId,
principalTable: "VaultSecrets",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_KeycloakConnectionId",
table: "VaultSecrets",
column: "KeycloakConnectionId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_AdminPasswordSecretId",
table: "KeycloakConnections",
column: "AdminPasswordSecretId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_KubernetesClusterId",
table: "KeycloakConnections",
column: "KubernetesClusterId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_TenantId",
table: "KeycloakConnections",
column: "TenantId");
migrationBuilder.AddForeignKey(
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
table: "KeycloakRealms",
column: "KeycloakConnectionId",
principalTable: "KeycloakConnections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId",
table: "VaultSecrets",
column: "KeycloakConnectionId",
principalTable: "KeycloakConnections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@@ -337,6 +337,9 @@ namespace EntKube.Web.Data.Migrations.Sqlite
.HasMaxLength(10)
.HasColumnType("TEXT");
b.Property<int>("RetentionDays")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
@@ -444,6 +447,46 @@ namespace EntKube.Web.Data.Migrations.Sqlite
b.ToTable("CustomerAccesses");
});
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AppDeploymentId")
.HasColumnType("TEXT");
b.Property<Guid?>("CnpgDatabaseId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("KubernetesSecretName")
.IsRequired()
.HasMaxLength(253)
.HasColumnType("TEXT");
b.Property<DateTime?>("LastSyncedAt")
.HasColumnType("TEXT");
b.Property<Guid?>("MongoDatabaseId")
.HasColumnType("TEXT");
b.Property<bool>("SyncEnabled")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppDeploymentId");
b.HasIndex("CnpgDatabaseId");
b.HasIndex("MongoDatabaseId");
b.ToTable("DatabaseBindings");
});
modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b =>
{
b.Property<Guid>("Id")
@@ -674,6 +717,140 @@ namespace EntKube.Web.Data.Migrations.Sqlite
b.ToTable("GroupMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("KeycloakRealmId")
.HasColumnType("TEXT");
b.Property<string>("LastError")
.HasColumnType("TEXT");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("RealmName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<Guid>("StorageLinkId")
.HasColumnType("TEXT");
b.Property<Guid>("TenantId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("KeycloakRealmId");
b.HasIndex("StorageLinkId");
b.ToTable("KeycloakBackups");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AdminUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("AdminUsername")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<Guid>("ClusterComponentId")
.HasColumnType("TEXT");
b.Property<Guid?>("CnpgDatabaseId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("TenantId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClusterComponentId");
b.HasIndex("CnpgDatabaseId");
b.HasIndex("TenantId");
b.ToTable("KeycloakComponentConfigs");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakRealm", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AccountTheme")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER");
b.Property<Guid>("KeycloakComponentConfigId")
.HasColumnType("TEXT");
b.Property<Guid?>("LinkedAppId")
.HasColumnType("TEXT");
b.Property<string>("LoginTheme")
.HasColumnType("TEXT");
b.Property<string>("RealmName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<Guid>("TenantId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LinkedAppId");
b.HasIndex("KeycloakComponentConfigId", "RealmName")
.IsUnique();
b.ToTable("KeycloakRealms");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Property<Guid>("Id")
@@ -715,6 +892,141 @@ namespace EntKube.Web.Data.Migrations.Sqlite
b.ToTable("KubernetesClusters");
});
modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<Guid>("MongoClusterId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(253)
.HasColumnType("TEXT");
b.Property<long?>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartedAt")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MongoClusterId", "Name")
.IsUnique();
b.ToTable("MongoBackups");
});
modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("BackupSchedule")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("KubernetesClusterId")
.HasColumnType("TEXT");
b.Property<string>("LastError")
.HasColumnType("TEXT");
b.Property<int>("Members")
.HasColumnType("INTEGER");
b.Property<string>("MongoVersion")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("TEXT");
b.Property<string>("Namespace")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("TEXT");
b.Property<int>("RetentionDays")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<Guid?>("StorageLinkId")
.HasColumnType("TEXT");
b.Property<string>("StorageSize")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<Guid>("TenantId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("StorageLinkId");
b.HasIndex("TenantId");
b.HasIndex("KubernetesClusterId", "Name", "Namespace")
.IsUnique();
b.ToTable("MongoClusters");
});
modelBuilder.Entity("EntKube.Web.Data.MongoDatabase", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("MongoClusterId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(63)
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MongoClusterId", "Name")
.IsUnique();
b.ToTable("MongoDatabases");
});
modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b =>
{
b.Property<Guid>("Id")
@@ -983,6 +1295,12 @@ namespace EntKube.Web.Data.Migrations.Sqlite
.HasMaxLength(253)
.HasColumnType("TEXT");
b.Property<Guid?>("MongoClusterId")
.HasColumnType("TEXT");
b.Property<Guid?>("MongoDatabaseId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -1015,6 +1333,10 @@ namespace EntKube.Web.Data.Migrations.Sqlite
b.HasIndex("ComponentId");
b.HasIndex("MongoClusterId");
b.HasIndex("MongoDatabaseId");
b.HasIndex("StorageLinkId");
b.HasIndex("VaultId", "AppId", "Name")
@@ -1300,6 +1622,31 @@ namespace EntKube.Web.Data.Migrations.Sqlite
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
{
b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment")
.WithMany("DatabaseBindings")
.HasForeignKey("AppDeploymentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase")
.WithMany("DatabaseBindings")
.HasForeignKey("CnpgDatabaseId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.MongoDatabase", "MongoDatabase")
.WithMany("DatabaseBindings")
.HasForeignKey("MongoDatabaseId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("AppDeployment");
b.Navigation("CnpgDatabase");
b.Navigation("MongoDatabase");
});
modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b =>
{
b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment")
@@ -1381,6 +1728,69 @@ namespace EntKube.Web.Data.Migrations.Sqlite
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b =>
{
b.HasOne("EntKube.Web.Data.KeycloakRealm", "Realm")
.WithMany("Backups")
.HasForeignKey("KeycloakRealmId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink")
.WithMany()
.HasForeignKey("StorageLinkId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Realm");
b.Navigation("StorageLink");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b =>
{
b.HasOne("EntKube.Web.Data.ClusterComponent", "ClusterComponent")
.WithMany()
.HasForeignKey("ClusterComponentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase")
.WithMany()
.HasForeignKey("CnpgDatabaseId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ClusterComponent");
b.Navigation("CnpgDatabase");
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakRealm", b =>
{
b.HasOne("EntKube.Web.Data.KeycloakComponentConfig", "ComponentConfig")
.WithMany("Realms")
.HasForeignKey("KeycloakComponentConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.App", "LinkedApp")
.WithMany()
.HasForeignKey("LinkedAppId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ComponentConfig");
b.Navigation("LinkedApp");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.HasOne("EntKube.Web.Data.Environment", "Environment")
@@ -1400,6 +1810,54 @@ namespace EntKube.Web.Data.Migrations.Sqlite
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b =>
{
b.HasOne("EntKube.Web.Data.MongoCluster", "MongoCluster")
.WithMany("Backups")
.HasForeignKey("MongoClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MongoCluster");
});
modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b =>
{
b.HasOne("EntKube.Web.Data.KubernetesCluster", "KubernetesCluster")
.WithMany()
.HasForeignKey("KubernetesClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink")
.WithMany()
.HasForeignKey("StorageLinkId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("KubernetesCluster");
b.Navigation("StorageLink");
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.MongoDatabase", b =>
{
b.HasOne("EntKube.Web.Data.MongoCluster", "MongoCluster")
.WithMany("Databases")
.HasForeignKey("MongoClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MongoCluster");
});
modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
@@ -1533,6 +1991,16 @@ namespace EntKube.Web.Data.Migrations.Sqlite
.HasForeignKey("ComponentId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.MongoCluster", "MongoCluster")
.WithMany()
.HasForeignKey("MongoClusterId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.MongoDatabase", "MongoDatabase")
.WithMany()
.HasForeignKey("MongoDatabaseId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink")
.WithMany()
.HasForeignKey("StorageLinkId");
@@ -1549,6 +2017,10 @@ namespace EntKube.Web.Data.Migrations.Sqlite
b.Navigation("Component");
b.Navigation("MongoCluster");
b.Navigation("MongoDatabase");
b.Navigation("StorageLink");
b.Navigation("Vault");
@@ -1616,6 +2088,8 @@ namespace EntKube.Web.Data.Migrations.Sqlite
modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b =>
{
b.Navigation("DatabaseBindings");
b.Navigation("Manifests");
b.Navigation("Resources");
@@ -1639,6 +2113,11 @@ namespace EntKube.Web.Data.Migrations.Sqlite
b.Navigation("Databases");
});
modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b =>
{
b.Navigation("DatabaseBindings");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Navigation("Apps");
@@ -1661,11 +2140,33 @@ namespace EntKube.Web.Data.Migrations.Sqlite
b.Navigation("Memberships");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b =>
{
b.Navigation("Realms");
});
modelBuilder.Entity("EntKube.Web.Data.KeycloakRealm", b =>
{
b.Navigation("Backups");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Navigation("Components");
});
modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b =>
{
b.Navigation("Backups");
b.Navigation("Databases");
});
modelBuilder.Entity("EntKube.Web.Data.MongoDatabase", b =>
{
b.Navigation("DatabaseBindings");
});
modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b =>
{
b.Navigation("StorageLinks");

View File

@@ -0,0 +1,186 @@
namespace EntKube.Web.Data;
/// <summary>
/// The lifecycle status of a managed Percona MongoDB cluster. Tracks where the
/// cluster is in the provisioning/operational/teardown cycle.
/// </summary>
public enum MongoClusterStatus
{
Creating,
Running,
Upgrading,
Restoring,
Failed,
Deleting
}
/// <summary>
/// A managed Percona Server for MongoDB cluster that EntKube provisions and controls.
/// Each cluster lives on a Kubernetes cluster where the Percona MongoDB operator is installed.
/// Optionally backed by a StorageLink (S3 bucket) for automated backups and point-in-time restore.
///
/// The cluster is represented as a PerconaServerMongoDB CRD (psmdb.percona.com/v1) in Kubernetes.
/// EntKube owns the full lifecycle: create, upgrade, backup, restore, delete.
/// </summary>
public class MongoCluster
{
public Guid Id { get; set; }
/// <summary>
/// The tenant that owns this cluster.
/// </summary>
public Guid TenantId { get; set; }
/// <summary>
/// The Kubernetes cluster where this MongoDB cluster runs.
/// Must have the Percona MongoDB operator installed.
/// </summary>
public Guid KubernetesClusterId { get; set; }
/// <summary>
/// The name of the PerconaServerMongoDB resource in Kubernetes (metadata.name).
/// Lowercase, DNS-safe, max 63 chars.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// The Kubernetes namespace where the cluster lives.
/// </summary>
public required string Namespace { get; set; }
/// <summary>
/// The MongoDB version (e.g. "8.0", "7.0", "6.0").
/// Maps to the Percona Server for MongoDB container image tag.
/// </summary>
public required string MongoVersion { get; set; }
/// <summary>
/// Number of replica set members (1 = standalone, 3 = HA with automatic failover).
/// </summary>
public int Members { get; set; } = 3;
/// <summary>
/// PVC storage size for each member (e.g. "10Gi", "50Gi").
/// </summary>
public required string StorageSize { get; set; }
/// <summary>
/// Optional link to an S3 bucket for automated backups and point-in-time restore.
/// When set, the cluster is configured with Percona Backup for MongoDB (PBM).
/// </summary>
public Guid? StorageLinkId { get; set; }
/// <summary>
/// Cron schedule for automated backups (e.g. "0 2 * * *" for daily at 2 AM).
/// Null means no scheduled backups — only on-demand.
/// </summary>
public string? BackupSchedule { get; set; }
/// <summary>
/// Number of days to retain backups. Percona Backup for MongoDB uses this
/// to auto-delete old backups. Default is 30 days.
/// </summary>
public int RetentionDays { get; set; } = 30;
/// <summary>
/// Current lifecycle status of the cluster.
/// </summary>
public MongoClusterStatus Status { get; set; } = MongoClusterStatus.Creating;
/// <summary>
/// The last error encountered during a lifecycle operation.
/// Cleared on successful operations.
/// </summary>
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Tenant Tenant { get; set; } = null!;
public KubernetesCluster KubernetesCluster { get; set; } = null!;
public StorageLink? StorageLink { get; set; }
public ICollection<MongoDatabase> Databases { get; set; } = [];
public ICollection<MongoBackup> Backups { get; set; } = [];
}
/// <summary>
/// The status of a database within a managed MongoDB cluster.
/// </summary>
public enum MongoDatabaseStatus
{
Creating,
Ready,
Failed
}
/// <summary>
/// A database (and its owner user) within a managed Percona MongoDB cluster.
/// MongoDB databases are created implicitly when data is first written, but
/// EntKube creates a dedicated user with readWrite permissions and stores
/// the connection string in the vault.
/// </summary>
public class MongoDatabase
{
public Guid Id { get; set; }
public Guid MongoClusterId { get; set; }
/// <summary>
/// The database name in MongoDB.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// The username that owns this database (has readWrite role).
/// </summary>
public required string Owner { get; set; }
public MongoDatabaseStatus Status { get; set; } = MongoDatabaseStatus.Creating;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public MongoCluster MongoCluster { get; set; } = null!;
public ICollection<DatabaseBinding> DatabaseBindings { get; set; } = [];
}
/// <summary>
/// The type of a MongoDB backup — scheduled or triggered on-demand.
/// </summary>
public enum MongoBackupType
{
Scheduled,
OnDemand
}
/// <summary>
/// The status of a MongoDB backup operation.
/// </summary>
public enum MongoBackupStatus
{
Running,
Completed,
Failed
}
/// <summary>
/// A backup record for a managed Percona MongoDB cluster.
/// Corresponds to a PerconaServerMongoDBBackup CRD in Kubernetes.
/// </summary>
public class MongoBackup
{
public Guid Id { get; set; }
public Guid MongoClusterId { get; set; }
/// <summary>
/// The name of the backup CR in Kubernetes.
/// </summary>
public required string Name { get; set; }
public MongoBackupType Type { get; set; } = MongoBackupType.OnDemand;
public MongoBackupStatus Status { get; set; } = MongoBackupStatus.Running;
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
public long? SizeBytes { get; set; }
// Navigation
public MongoCluster MongoCluster { get; set; } = null!;
}

View File

@@ -56,6 +56,16 @@ public class VaultSecret
/// </summary>
public Guid? CnpgDatabaseId { get; set; }
/// <summary>
/// If set, this secret belongs to a MongoDB database (connection credentials).
/// </summary>
public Guid? MongoDatabaseId { get; set; }
/// <summary>
/// If set, this secret belongs to a managed MongoDB cluster (e.g. the admin password).
/// </summary>
public Guid? MongoClusterId { get; set; }
/// <summary>
/// When true, this secret will be synced to Kubernetes as a Secret resource.
/// </summary>
@@ -82,4 +92,6 @@ public class VaultSecret
public ClusterComponent? Component { get; set; }
public StorageLink? StorageLink { get; set; }
public CnpgDatabase? CnpgDatabase { get; set; }
public MongoDatabase? MongoDatabase { get; set; }
public MongoCluster? MongoCluster { get; set; }
}

View File

@@ -108,10 +108,12 @@ public class Program
builder.Services.AddScoped<ExternalRouteService>();
builder.Services.AddScoped<DatabaseService>();
builder.Services.AddScoped<CnpgService>();
builder.Services.AddScoped<MongoService>();
builder.Services.AddScoped<IKubernetesClientFactory, KubernetesClientFactory>();
builder.Services.AddScoped<OpenStackS3Service>();
builder.Services.AddScoped<StorageService>();
builder.Services.AddScoped<ComponentScanService>();
builder.Services.AddScoped<KeycloakService>();
WebApplication app = builder.Build();

File diff suppressed because it is too large Load Diff

View File

@@ -60,14 +60,6 @@ public class CatalogEntry
/// </summary>
public IReadOnlyList<DependencyRequirement> RequiresOneOf { get; init; } = [];
/// <summary>
/// Companion components that should be auto-installed alongside this one.
/// When this component installs successfully, any companions that don't already
/// exist on the cluster will be registered and installed automatically.
/// Unlike Dependencies (which block install if missing), companions are additive.
/// </summary>
public IReadOnlyList<string> CompanionKeys { get; init; } = [];
/// <summary>
/// Form fields that provide a user-friendly way to configure the most common
/// Helm values. These render as simple form controls (text boxes, selects,
@@ -112,6 +104,22 @@ public static class ComponentCatalog
[
// ── Ingress ──
new CatalogEntry
{
Key = "gateway-api-crds",
DisplayName = "Gateway API CRDs",
Description = "Installs the Kubernetes Gateway API CRDs (experimental channel): HTTPRoute, TCPRoute, TLSRoute, UDPRoute, GRPCRoute, Gateway, GatewayClass. Required by Traefik and Istio Gateway API support.",
Icon = "bi-diagram-2",
Category = "Ingress",
ComponentType = "ManifestUrl",
HelmRepoUrl = "https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/experimental-install.yaml",
HelmChartName = "",
DefaultNamespace = "gateway-system",
DefaultReleaseName = "gateway-api-crds",
FormFields = [],
DefaultValues = ""
},
new CatalogEntry
{
Key = "traefik",
@@ -146,10 +154,10 @@ public static class ComponentCatalog
},
new ComponentFormField
{
Key = "gateway-api-crds", Label = "Install Gateway API CRDs",
Key = "gateway-api-provider", Label = "Enable Gateway API Provider",
YamlPath = "gatewayAPI.enabled", Type = FormFieldType.Toggle,
DefaultValue = "true",
HelpText = "Install the Gateway API CRD definitions with this chart"
HelpText = "Enable Traefik's Gateway API provider (requires gateway-api-crds installed)"
},
new ComponentFormField
{
@@ -203,10 +211,10 @@ public static class ComponentCatalog
memory: 128Mi
cpu: 100m
# Install Gateway API CRDs
gatewayAPI:
enabled: true
"""
""",
Dependencies = ["gateway-api-crds"]
},
new CatalogEntry
@@ -220,7 +228,7 @@ public static class ComponentCatalog
HelmChartName = "gateway",
DefaultNamespace = "istio-system",
DefaultReleaseName = "istio-ingress-external",
Dependencies = ["istio-base"],
Dependencies = ["gateway-api-crds", "istio-base"],
FormFields =
[
new ComponentFormField
@@ -270,7 +278,7 @@ public static class ComponentCatalog
HelmChartName = "gateway",
DefaultNamespace = "istio-system",
DefaultReleaseName = "istio-ingress-internal",
Dependencies = ["istio-base"],
Dependencies = ["gateway-api-crds", "istio-base"],
FormFields =
[
new ComponentFormField
@@ -325,50 +333,39 @@ public static class ComponentCatalog
new CatalogEntry
{
Key = "istio-base",
DisplayName = "Istio Base (istiod)",
Description = "Istio control plane (istiod). Required foundation for Istio service mesh — provides the control plane that manages proxies, certificates, and configuration.",
DisplayName = "Istio Control Plane",
Description = "Installs Istio CRDs (base) and the istiod control plane in one step. Required foundation for Istio service mesh — manages proxies, certificates, and Gateway API routing.",
Icon = "bi-diagram-3",
Category = "Ingress",
HelmRepoUrl = "https://istio-release.storage.googleapis.com/charts",
HelmChartName = "istiod",
HelmChartName = "base",
DefaultNamespace = "istio-system",
DefaultReleaseName = "istiod",
DefaultReleaseName = "istio-base",
FormFields =
[
new ComponentFormField
{
Key = "gateway-api", Label = "Enable Gateway API",
YamlPath = "pilot.env.PILOT_ENABLE_GATEWAY_API", Type = FormFieldType.Toggle,
Key = "install-istiod", Label = "Install istiod control plane",
YamlPath = "subchart:istiod", Type = FormFieldType.Toggle,
DefaultValue = "true",
HelpText = "Enable Gateway API support in the Istio control plane"
},
new ComponentFormField
{
Key = "cpu-request", Label = "CPU Request",
YamlPath = "resources.requests.cpu", Type = FormFieldType.Text,
DefaultValue = "100m", Placeholder = "e.g. 100m, 250m"
},
new ComponentFormField
{
Key = "memory-request", Label = "Memory Request",
YamlPath = "resources.requests.memory", Type = FormFieldType.Text,
DefaultValue = "256Mi", Placeholder = "e.g. 256Mi, 512Mi"
HelpText = "Install the Istio control plane (istiod) alongside the base CRDs",
SubchartDefaultValues = """
# Enable Gateway API support in istiod
pilot:
env:
PILOT_ENABLE_GATEWAY_API: "true"
PILOT_ENABLE_GATEWAY_API_STATUS: "true"
PILOT_ENABLE_GATEWAY_API_DEPLOYMENT_CONTROLLER: "true"
# Resource allocation
resources:
requests:
memory: 256Mi
cpu: 100m
"""
}
],
DefaultValues = """
# Enable Gateway API support in istiod
pilot:
env:
PILOT_ENABLE_GATEWAY_API: "true"
PILOT_ENABLE_GATEWAY_API_STATUS: "true"
PILOT_ENABLE_GATEWAY_API_DEPLOYMENT_CONTROLLER: "true"
# Resource allocation
resources:
requests:
memory: 256Mi
cpu: 100m
"""
Dependencies = ["gateway-api-crds"]
},
// ── Certificate Management ──
@@ -394,13 +391,6 @@ public static class ComponentCatalog
HelpText = "Install cert-manager Custom Resource Definitions"
},
new ComponentFormField
{
Key = "gateway-api", Label = "Gateway API Integration",
YamlPath = "featureGates", Type = FormFieldType.Text,
DefaultValue = "ExperimentalGatewayAPISupport=true",
HelpText = "Feature gates to enable (Gateway API support recommended)"
},
new ComponentFormField
{
Key = "cpu-request", Label = "CPU Request",
YamlPath = "resources.requests.cpu", Type = FormFieldType.Text,
@@ -417,11 +407,7 @@ public static class ComponentCatalog
# Install CRDs with the chart
crds:
enabled: true
# Enable Gateway API integration
# cert-manager will watch Gateway resources for TLS config
featureGates: ExperimentalGatewayAPISupport=true
# Resource allocation
resources:
requests:
@@ -434,11 +420,11 @@ public static class ComponentCatalog
{
Key = "letsencrypt-issuer",
DisplayName = "Let's Encrypt ClusterIssuer",
Description = "Configures a ClusterIssuer that uses Let's Encrypt to automatically issue and renew TLS certificates. Works with both HTTP-01 and DNS-01 challenge types.",
Description = "Configures a ClusterIssuer that uses Let's Encrypt to automatically issue and renew TLS certificates. Supports HTTP-01 (ingress) and DNS-01 (Azure DNS) challenge solvers — both can be active simultaneously.",
Icon = "bi-lock",
Category = "Certificate Management",
HelmRepoUrl = "https://charts.jetstack.io",
HelmChartName = "cert-manager",
HelmChartName = "letsencrypt-issuer",
DefaultNamespace = "cert-manager",
DefaultReleaseName = "letsencrypt-issuer",
Dependencies = ["cert-manager"],
@@ -460,13 +446,60 @@ public static class ComponentCatalog
DefaultValue = "https://acme-v02.api.letsencrypt.org/directory",
Options = ["https://acme-v02.api.letsencrypt.org/directory", "https://acme-staging-v02.api.letsencrypt.org/directory"],
HelpText = "Use staging for testing, production for real certificates"
},
new ComponentFormField
{
Key = "dns-zone", Label = "DNS-01 Hosted Zone",
YamlPath = "spec.acme.solvers.0.dns01.azureDNS.hostedZoneName",
Type = FormFieldType.Text,
Placeholder = "example.com",
HelpText = "Azure DNS zone name (e.g. example.com)"
},
new ComponentFormField
{
Key = "dns-resource-group", Label = "DNS Resource Group",
YamlPath = "spec.acme.solvers.0.dns01.azureDNS.resourceGroupName",
Type = FormFieldType.Text,
Placeholder = "my-dns-rg",
HelpText = "Azure resource group containing the DNS zone"
},
new ComponentFormField
{
Key = "dns-subscription-id", Label = "DNS Subscription ID",
YamlPath = "spec.acme.solvers.0.dns01.azureDNS.subscriptionID",
Type = FormFieldType.Text,
Placeholder = "00000000-0000-0000-0000-000000000000",
HelpText = "Azure subscription ID containing the DNS zone"
},
new ComponentFormField
{
Key = "dns-tenant-id", Label = "DNS Tenant ID",
YamlPath = "spec.acme.solvers.0.dns01.azureDNS.tenantID",
Type = FormFieldType.Text,
Placeholder = "00000000-0000-0000-0000-000000000000",
HelpText = "Azure AD tenant ID for the service principal"
},
new ComponentFormField
{
Key = "dns-client-id", Label = "DNS Client ID",
YamlPath = "spec.acme.solvers.0.dns01.azureDNS.clientID",
Type = FormFieldType.Text,
Placeholder = "00000000-0000-0000-0000-000000000000",
HelpText = "Service principal (app registration) client ID"
},
new ComponentFormField
{
Key = "dns-client-secret", Label = "DNS Client Secret",
YamlPath = "",
Type = FormFieldType.Password,
StoreAsSecret = true,
SecretName = "azuredns-client-secret",
KubernetesSecretName = "azuredns-config",
KubernetesSecretNamespace = "cert-manager",
HelpText = "Service principal client secret — stored in vault and synced to K8s Secret 'azuredns-config'"
}
],
DefaultValues = """
# This component applies a ClusterIssuer manifest via Helm.
# Change the email to your operations contact.
# Let's Encrypt Production
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
@@ -478,11 +511,17 @@ public static class ComponentCatalog
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- name: traefik-gateway
namespace: traefik
- dns01:
azureDNS:
hostedZoneName: example.com
resourceGroupName: my-dns-rg
subscriptionID: 00000000-0000-0000-0000-000000000000
tenantID: 00000000-0000-0000-0000-000000000000
clientID: 00000000-0000-0000-0000-000000000000
environment: AzurePublicCloud
clientSecretSecretRef:
name: azuredns-config
key: azuredns-client-secret
"""
},
@@ -688,9 +727,14 @@ public static class ComponentCatalog
HelmChartName = "cloudnative-pg",
DefaultNamespace = "cnpg-system",
DefaultReleaseName = "cnpg",
CompanionKeys = ["barman-cloud-plugin"],
FormFields =
[
new ComponentFormField
{
Key = "barman-cloud-plugin", Label = "Install Barman Cloud Plugin",
YamlPath = "subchart:plugin-barman-cloud", Type = FormFieldType.Toggle,
DefaultValue = "true"
},
new ComponentFormField
{
Key = "cpu-request", Label = "CPU Request",
@@ -715,27 +759,9 @@ public static class ComponentCatalog
new CatalogEntry
{
Key = "barman-cloud-plugin",
DisplayName = "Barman Cloud Plugin",
Description = "Backup and recovery plugin for CloudNativePG. Provides S3-compatible backup via Barman Cloud, WAL archiving, and point-in-time recovery. Required for CNPG backup/restore operations.",
Icon = "bi-cloud-arrow-up",
Category = "Databases",
HelmRepoUrl = "https://cloudnative-pg.github.io/charts",
HelmChartName = "barman-cloud",
DefaultNamespace = "cnpg-system",
DefaultReleaseName = "barman-cloud",
Dependencies = ["cloudnative-pg"],
DefaultValues = """
# Barman Cloud Plugin configuration
# Installed as companion to CloudNativePG operator
"""
},
new CatalogEntry
{
Key = "mongodb-operator",
Key = "mongodb-community-operator",
DisplayName = "MongoDB Community Operator",
Description = "Kubernetes operator for deploying and managing MongoDB replica sets. Handles provisioning, scaling, upgrades, and TLS configuration.",
Description = "Official MongoDB Kubernetes operator for deploying and managing MongoDB replica sets. Handles provisioning, scaling, upgrades, and user management. Backups are managed independently via S3-backed Jobs.",
Icon = "bi-database-gear",
Category = "Databases",
HelmRepoUrl = "https://mongodb.github.io/helm-charts",
@@ -799,6 +825,70 @@ public static class ComponentCatalog
Dependencies = ["cert-manager", "letsencrypt-issuer", "cloudnative-pg"],
FormFields =
[
new ComponentFormField
{
Key = "cnpg-database", Label = "Database",
YamlPath = "cnpg:database-id", Type = FormFieldType.CnpgDatabase,
HelpText = "Managed PostgreSQL database for Keycloak (credentials synced to K8s Secret)"
},
new ComponentFormField
{
Key = "admin-url", Label = "Admin URL",
YamlPath = "cnpg:admin-url", Type = FormFieldType.Text,
Placeholder = "https://keycloak.example.com/auth",
HelpText = "Public URL of the Keycloak admin console"
},
new ComponentFormField
{
Key = "admin-username", Label = "Admin Username",
YamlPath = "cnpg:admin-username", Type = FormFieldType.Text,
DefaultValue = "admin"
},
new ComponentFormField
{
Key = "admin-password", Label = "Admin Password",
YamlPath = "cnpg:admin-password", Type = FormFieldType.Password,
Placeholder = "Strong password for the Keycloak admin account",
HelpText = "Stored in vault and synced to Kubernetes Secret"
},
new ComponentFormField
{
Key = "hostname", Label = "Public Hostname",
YamlPath = "cnpg:hostname", Type = FormFieldType.Text,
Placeholder = "keycloak.example.com",
HelpText = "Creates an HTTPRoute and Certificate to publish Keycloak"
},
new ComponentFormField
{
Key = "tls-mode", Label = "TLS Mode",
YamlPath = "cnpg:tls-mode", Type = FormFieldType.Select,
DefaultValue = "ClusterIssuer",
Options = ["ClusterIssuer", "Manual"]
},
new ComponentFormField
{
Key = "cluster-issuer", Label = "Cluster Issuer",
YamlPath = "cnpg:cluster-issuer", Type = FormFieldType.ClusterIssuer,
DefaultValue = "letsencrypt-prod",
HelpText = "cert-manager ClusterIssuer to request the TLS certificate",
DependsOnKey = "tls-mode", DependsOnValue = "ClusterIssuer"
},
new ComponentFormField
{
Key = "tls-cert", Label = "TLS Certificate (PEM)",
YamlPath = "cnpg:tls-cert", Type = FormFieldType.Password,
Placeholder = "-----BEGIN CERTIFICATE-----",
HelpText = "Full certificate chain in PEM format",
DependsOnKey = "tls-mode", DependsOnValue = "Manual"
},
new ComponentFormField
{
Key = "tls-key", Label = "TLS Private Key (PEM)",
YamlPath = "cnpg:tls-key", Type = FormFieldType.Password,
Placeholder = "-----BEGIN PRIVATE KEY-----",
HelpText = "Private key in PEM format",
DependsOnKey = "tls-mode", DependsOnValue = "Manual"
},
new ComponentFormField
{
Key = "http-path", Label = "HTTP Relative Path",
@@ -807,14 +897,6 @@ public static class ComponentCatalog
HelpText = "Base path where Keycloak is served"
},
new ComponentFormField
{
Key = "db-vendor", Label = "Database Vendor",
YamlPath = "database.vendor", Type = FormFieldType.Select,
DefaultValue = "postgres",
Options = ["postgres", "mariadb", "mysql"],
HelpText = "Database backend for Keycloak"
},
new ComponentFormField
{
Key = "cpu-request", Label = "CPU Request",
YamlPath = "resources.requests.cpu", Type = FormFieldType.Text,
@@ -828,14 +910,40 @@ public static class ComponentCatalog
}
],
DefaultValues = """
# Database configuration (requires CloudNativePG)
# Database vendor credentials (KC_DB_URL/USERNAME/PASSWORD) are injected via the
# K8s Secret below, not set here directly.
database:
vendor: postgres
# All env vars (KC_BOOTSTRAP_ADMIN_USERNAME, KC_BOOTSTRAP_ADMIN_PASSWORD,
# KC_DB_URL, KC_DB_USERNAME, KC_DB_PASSWORD) are written to this secret by
# EntKube and read here at startup. The name must match {releaseName}-credentials.
extraEnvFrom: |
- secretRef:
name: keycloak-credentials
# Disable the chart's legacy proxy setting it injects KC_PROXY which was
# removed in Keycloak 26 and causes kc.sh to exit with the help text.
proxy:
enabled: false
# Keycloak 26+ proxy configuration replaces KC_PROXY=edge.
# KC_HTTP_ENABLED is set by the chart via http.relativePath omit here to avoid duplicates.
extraEnv: |
- name: KC_PROXY_HEADERS
value: xforwarded
- name: KC_HOSTNAME_STRICT
value: "false"
# Explicit start args overrides the chart's default which may include
# --hostname-strict=false (removed in Keycloak 26, causes help-text crash).
args:
- start
# HTTP configuration
http:
relativePath: /auth
# Resource allocation
resources:
requests:
@@ -845,6 +953,30 @@ public static class ComponentCatalog
}
];
/// <summary>
/// Returns a mapping of subchart release names to their parent catalog key.
/// For example: {"plugin-barman-cloud" → "cloudnative-pg"}.
/// Used during scan to identify releases that are subcharts of a parent.
/// </summary>
public static Dictionary<string, string> GetSubchartParents()
{
Dictionary<string, string> result = new(StringComparer.OrdinalIgnoreCase);
foreach (CatalogEntry entry in Entries)
{
foreach (ComponentFormField field in entry.FormFields)
{
if (field.YamlPath.StartsWith("subchart:", StringComparison.Ordinal))
{
string subchartName = field.YamlPath["subchart:".Length..];
result[subchartName] = entry.Key;
}
}
}
return result;
}
/// <summary>
/// Looks up a catalog entry by its key. Returns null if not found.
/// </summary>
@@ -859,11 +991,28 @@ public static class ComponentCatalog
/// </summary>
public static CatalogEntry? FindByRelease(string releaseName, string? chartName)
{
return Entries.FirstOrDefault(e =>
// First try exact matches on Key, DefaultReleaseName, or HelmChartName.
CatalogEntry? exact = Entries.FirstOrDefault(e =>
string.Equals(e.Key, releaseName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.DefaultReleaseName, releaseName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.HelmChartName, releaseName, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(chartName) && string.Equals(e.HelmChartName, chartName, StringComparison.OrdinalIgnoreCase)));
if (exact is not null)
{
return exact;
}
// Fall back to partial matching — the release name might contain the
// catalog's DefaultReleaseName or Key as a substring, or vice versa.
// For example, release "cloudnative-pg-cnpg" matching entry with Key "cloudnative-pg".
return Entries.FirstOrDefault(e =>
(!string.IsNullOrEmpty(e.DefaultReleaseName)
&& (releaseName.Contains(e.DefaultReleaseName, StringComparison.OrdinalIgnoreCase)
|| e.DefaultReleaseName.Contains(releaseName, StringComparison.OrdinalIgnoreCase)))
|| releaseName.Contains(e.Key, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
@@ -875,11 +1024,13 @@ public static class ComponentCatalog
public static bool IsComponentMatch(string componentName, CatalogEntry entry,
string? helmChartName = null, string? releaseName = null)
{
// Direct matches on Key, DefaultReleaseName, or HelmChartName.
// Direct match on Key or DefaultReleaseName.
// Intentionally does NOT match componentName against entry.HelmChartName —
// that path would cause "cert-manager" to match the letsencrypt-issuer entry.
// HelmChartName matching is handled below via the component's own HelmChartName field.
if (string.Equals(componentName, entry.Key, StringComparison.OrdinalIgnoreCase)
|| string.Equals(componentName, entry.DefaultReleaseName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(componentName, entry.HelmChartName, StringComparison.OrdinalIgnoreCase))
|| string.Equals(componentName, entry.DefaultReleaseName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
@@ -890,9 +1041,14 @@ public static class ComponentCatalog
if (!string.IsNullOrEmpty(helmChartName)
&& string.Equals(helmChartName, entry.HelmChartName, StringComparison.OrdinalIgnoreCase))
{
// HelmChartName alone can be ambiguous (e.g. both istio-external and istio-internal use "gateway").
// Also check that the component's HelmRepoUrl matches to disambiguate.
// But for now, if the chart name matches, treat it as installed.
// Chart name alone is ambiguous when multiple catalog entries share the same chart
// (e.g. "istio" and "istio-internal" both use the "gateway" chart).
// Disambiguate using the release name when both sides have one.
if (!string.IsNullOrEmpty(releaseName) && !string.IsNullOrEmpty(entry.DefaultReleaseName))
{
return string.Equals(releaseName, entry.DefaultReleaseName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(releaseName, entry.Key, StringComparison.OrdinalIgnoreCase);
}
return true;
}
@@ -920,7 +1076,18 @@ public static class ComponentCatalog
foreach ((string name, string? chartName, string? releaseName) in components)
{
CatalogEntry? match = Entries.FirstOrDefault(e => IsComponentMatch(name, e, chartName, releaseName));
// Prefer a direct key/releaseName match to avoid HelmChartName collisions.
// Example: letsencrypt-issuer has HelmChartName="cert-manager", so a fuzzy
// first-match would incorrectly resolve it to the cert-manager catalog entry.
CatalogEntry? match =
Entries.FirstOrDefault(e =>
string.Equals(name, e.Key, StringComparison.OrdinalIgnoreCase)
|| string.Equals(name, e.DefaultReleaseName, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(releaseName)
&& (string.Equals(releaseName, e.Key, StringComparison.OrdinalIgnoreCase)
|| string.Equals(releaseName, e.DefaultReleaseName, StringComparison.OrdinalIgnoreCase))))
?? Entries.FirstOrDefault(e => IsComponentMatch(name, e, chartName, releaseName));
resolved.Add(match?.Key ?? name);
}

View File

@@ -41,6 +41,10 @@ public class HelmCommand
public string? Version { get; set; }
public bool HasValues { get; set; }
public string? ValuesYaml { get; set; }
/// <summary>For kubectl-apply-url: the remote manifest URL passed directly to kubectl apply -f.</summary>
public string? ManifestUrl { get; set; }
/// <summary>When true, skips --wait so Helm returns immediately after applying values.</summary>
public bool NoWait { get; set; }
}
/// <summary>
@@ -178,9 +182,9 @@ public class ComponentLifecycleService(IDbContextFactory<ApplicationDbContext> d
"Component has an operation in progress. Wait for it to complete.");
}
// Validate the minimum required fields for a Helm install.
// Validate minimum required fields — ManifestUrl components use a URL instead of a chart name.
if (string.IsNullOrWhiteSpace(component.HelmChartName))
if (component.ComponentType != "ManifestUrl" && string.IsNullOrWhiteSpace(component.HelmChartName))
{
throw new InvalidOperationException(
"Helm chart name is required. Configure the component before installing.");
@@ -227,77 +231,225 @@ public class ComponentLifecycleService(IDbContextFactory<ApplicationDbContext> d
/// <summary>
/// Checks if a successfully installed component has companion charts defined
/// in the catalog. If so, registers and installs each companion that doesn't
/// already exist on the cluster. Called after a successful install/upgrade.
/// Checks if a component has any subchart toggle fields enabled (YamlPath
/// starting with "subchart:"). For each enabled subchart, runs a helm
/// upgrade --install using the same repo URL and namespace as the parent.
/// The chart name is extracted from the YamlPath (e.g. "subchart:barman-cloud").
///
/// For example: installing cloudnative-pg will auto-install barman-cloud-plugin.
/// For example: cloudnative-pg with "barman-cloud-plugin" toggle enabled will
/// install the "barman-cloud" chart from the same CNPG charts repo.
/// </summary>
public async Task<List<HelmExecutionResult>> InstallCompanionsAsync(
public async Task<HelmExecutionResult> InstallSubchartsAsync(
Guid componentId, CancellationToken ct = default)
{
List<HelmExecutionResult> results = [];
using ApplicationDbContext db = dbFactory.CreateDbContext();
ClusterComponent component = await db.ClusterComponents
.Include(c => c.Cluster)
.FirstOrDefaultAsync(c => c.Id == componentId, ct)
?? throw new InvalidOperationException("Component not found.");
// Look up the catalog entry for this component to find its companions.
// Look up the catalog entry to find subchart toggle fields.
CatalogEntry? entry = ComponentCatalog.GetByKey(component.Name);
if (entry is null || entry.CompanionKeys.Count == 0)
if (entry is null)
{
return results;
return new HelmExecutionResult { Success = true, Output = "" };
}
// For each companion, check if it already exists on this cluster.
// If not, register it and run a full install cycle.
// Parse the stored form field values from the component's HelmValues isn't
// how toggles work — they're stored directly. We need to read them from
// the catalog defaults + any override. For subchart toggles, the value is
// stored as part of the component's configuration via editFormFieldValues.
// Since these don't go into YAML, we check the catalog default.
foreach (string companionKey in entry.CompanionKeys)
StringBuilder output = new();
bool allSuccess = true;
foreach (ComponentFormField field in entry.FormFields)
{
CatalogEntry? companion = ComponentCatalog.GetByKey(companionKey);
if (companion is null)
if (!field.YamlPath.StartsWith("subchart:", StringComparison.Ordinal))
{
continue;
}
bool alreadyExists = await db.ClusterComponents
.AnyAsync(c => c.ClusterId == component.ClusterId && c.Name == companionKey, ct);
// The chart name is after the "subchart:" prefix.
if (alreadyExists)
string subchartName = field.YamlPath["subchart:".Length..];
// Determine if the toggle is enabled. Check if the component has stored
// a "false" override — otherwise default to the catalog default value.
bool enabled = IsSubchartEnabled(component, field);
if (!enabled)
{
continue;
}
// Register the companion component.
// Build and execute the helm install for the subchart using the parent's
// repo URL and namespace.
ComponentRegistration registration = ComponentCatalog.ToRegistration(companion);
ClusterComponent companionComponent = await RegisterComponentAsync(
component.ClusterId, registration, ct);
string repoUrl = component.HelmRepoUrl ?? entry.HelmRepoUrl ?? "";
string ns = component.Namespace ?? entry.DefaultNamespace ?? "default";
string kubeconfig = component.Cluster.Kubeconfig ?? "";
// Run the full install cycle: prepare → get command → execute → mark result.
try
if (string.IsNullOrWhiteSpace(kubeconfig))
{
await PrepareInstallAsync(companionComponent.Id, ct);
HelmCommand command = await GetInstallCommandAsync(companionComponent.Id, ct);
HelmExecutionResult result = await ExecuteHelmAsync(companionComponent.Id, command, ct);
await MarkInstallResultAsync(companionComponent.Id, result.Success,
result.Success ? null : result.Output, ct);
results.Add(result);
return new HelmExecutionResult
{
Success = false,
Output = "No kubeconfig stored for this cluster."
};
}
catch (Exception ex)
string? subchartValues = !string.IsNullOrWhiteSpace(field.SubchartDefaultValues)
? field.SubchartDefaultValues
: null;
HelmCommand subCommand = new()
{
await MarkInstallResultAsync(companionComponent.Id, false, ex.Message, ct);
results.Add(new HelmExecutionResult { Success = false, Output = ex.Message });
Operation = "upgrade --install",
ReleaseName = subchartName,
ChartReference = $"{repoUrl}/{subchartName}",
Namespace = ns,
RepoUrl = repoUrl,
HasValues = subchartValues is not null,
ValuesYaml = subchartValues
};
HelmExecutionResult result = await ExecuteHelmAsync(componentId, subCommand, ct);
output.AppendLine($"--- Subchart: {subchartName} ---");
output.AppendLine(result.Output);
if (!result.Success)
{
allSuccess = false;
}
}
return results;
return new HelmExecutionResult
{
Success = allSuccess,
Output = output.ToString()
};
}
/// <summary>
/// Determines if a subchart toggle is enabled for a component by checking
/// whether the component's HelmValues contains a marker comment for the field.
/// Since subchart toggles don't map to YAML paths, we check the default value
/// from the catalog — unless the component has an explicit override stored.
/// </summary>
private static bool IsSubchartEnabled(ClusterComponent component, ComponentFormField field)
{
// Subchart toggles store their value as a comment marker in HelmValues:
// "# subchart:barman-cloud=true" or "# subchart:barman-cloud=false"
// If no marker exists, fall back to the catalog default.
string marker = $"# {field.YamlPath}=";
if (!string.IsNullOrWhiteSpace(component.HelmValues))
{
foreach (string line in component.HelmValues.Split('\n'))
{
if (line.TrimStart().StartsWith(marker, StringComparison.Ordinal))
{
string value = line.TrimStart()[marker.Length..].Trim();
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
}
}
// No explicit override — use catalog default.
return string.Equals(field.DefaultValue, "true", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Uninstalls any subcharts (e.g. Barman Cloud Plugin) that were installed
/// alongside the parent component. The subchart list is derived from the
/// catalog entry's toggle fields; only enabled subcharts are removed.
/// Returns the combined Helm output for all subchart uninstalls.
/// </summary>
public async Task<HelmExecutionResult> UninstallSubchartsAsync(
Guid componentId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
ClusterComponent component = await db.ClusterComponents
.Include(c => c.Cluster)
.FirstOrDefaultAsync(c => c.Id == componentId, ct)
?? throw new InvalidOperationException("Component not found.");
CatalogEntry? entry = ComponentCatalog.GetByKey(component.Name);
if (entry is null)
{
return new HelmExecutionResult { Success = true, Output = "" };
}
StringBuilder output = new();
bool allSuccess = true;
foreach (ComponentFormField field in entry.FormFields)
{
if (!field.YamlPath.StartsWith("subchart:", StringComparison.Ordinal))
{
continue;
}
string subchartName = field.YamlPath["subchart:".Length..];
if (!IsSubchartEnabled(component, field))
{
continue;
}
string ns = component.Namespace ?? entry.DefaultNamespace ?? "default";
string kubeconfig = component.Cluster.Kubeconfig ?? "";
if (string.IsNullOrWhiteSpace(kubeconfig))
{
return new HelmExecutionResult
{
Success = false,
Output = "No kubeconfig stored for this cluster."
};
}
HelmCommand subCommand = new()
{
Operation = "uninstall",
ReleaseName = subchartName,
Namespace = ns
};
HelmExecutionResult result = await ExecuteHelmAsync(componentId, subCommand, ct);
// "release: not found" means the subchart was never installed (e.g. the install
// failed before Helm recorded the release). Treat this as already-uninstalled
// so a missing subchart never blocks the parent component from being removed.
if (!result.Success && result.Output.Contains("not found", StringComparison.OrdinalIgnoreCase))
{
result = new HelmExecutionResult { Success = true, Output = result.Output };
}
output.AppendLine($"--- Subchart: {subchartName} ---");
output.AppendLine(result.Output);
if (!result.Success)
{
allSuccess = false;
}
}
return new HelmExecutionResult
{
Success = allSuccess,
Output = output.ToString()
};
}
/// <summary>
@@ -408,6 +560,19 @@ public class ComponentLifecycleService(IDbContextFactory<ApplicationDbContext> d
};
}
// ManifestUrl components use kubectl apply -f <url> directly.
// HelmRepoUrl holds the manifest URL; no local YAML needed.
if (component.ComponentType == "ManifestUrl")
{
return new HelmCommand
{
Operation = "kubectl-apply-url",
ReleaseName = component.ReleaseName ?? component.Name,
ManifestUrl = component.HelmRepoUrl
};
}
// Resolve any vault secrets and inject them into the values YAML.
// Secret form fields (like Grafana admin password) are stored encrypted
// in the vault rather than in plain text in HelmValues. At install time,
@@ -462,6 +627,17 @@ public class ComponentLifecycleService(IDbContextFactory<ApplicationDbContext> d
};
}
// ManifestUrl CRD bundles are cluster-scoped infra — skip uninstall to avoid breaking dependents.
if (component.ComponentType == "ManifestUrl")
{
return new HelmCommand
{
Operation = "noop",
ReleaseName = releaseName
};
}
return new HelmCommand
{
Operation = "uninstall",
@@ -614,6 +790,10 @@ public class ComponentLifecycleService(IDbContextFactory<ApplicationDbContext> d
string k8sSecretName = group.Key.SecretName;
string ns = group.Key.Namespace;
// Ensure the namespace exists before writing the secret.
// The pod needs the secret at startup, which may be before Helm creates the namespace.
await RunProcessAsync("kubectl", $"create namespace {ns} --kubeconfig {tempKubeconfig}", ct);
// Decrypt each secret value and build --from-literal args.
List<string> literals = [];
@@ -707,11 +887,21 @@ public class ComponentLifecycleService(IDbContextFactory<ApplicationDbContext> d
// Route to the appropriate executor based on operation type.
if (command.Operation == "noop")
{
return new HelmExecutionResult { Success = true, Output = "No action taken (CRD bundles are not uninstalled automatically)." };
}
if (command.Operation is "kubectl-apply" or "kubectl-delete")
{
return await ExecuteKubectlAsync(command, tempKubeconfig, ct);
}
if (command.Operation == "kubectl-apply-url")
{
return await ExecuteKubectlUrlAsync(command, tempKubeconfig, ct);
}
return await ExecuteHelmCliAsync(command, tempKubeconfig, component.Cluster.Kubeconfig, ct);
}
finally
@@ -775,6 +965,94 @@ public class ComponentLifecycleService(IDbContextFactory<ApplicationDbContext> d
}
}
/// <summary>
/// Applies a remote manifest URL directly via kubectl apply -f &lt;url&gt;.
/// Used for components like Gateway API CRDs where the authoritative source
/// is a GitHub release manifest rather than a Helm chart.
/// </summary>
/// <summary>
/// Applies all ExternalRoute resources for a component to its cluster via kubectl.
/// Generates an HTTPRoute + Certificate manifest for each route and applies them.
/// Safe to call repeatedly — kubectl apply is idempotent.
/// </summary>
public async Task<HelmExecutionResult> ApplyExternalRoutesAsync(
Guid componentId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
// Load the full cluster (all components + their routes) so the Gateway manifest
// covers every exposed hostname — not just this component's routes.
ClusterComponent component = await db.ClusterComponents
.Include(c => c.Cluster)
.ThenInclude(cl => cl.Components)
.ThenInclude(comp => comp.ExternalRoutes)
.FirstOrDefaultAsync(c => c.Id == componentId, ct)
?? throw new InvalidOperationException("Component not found.");
List<ExternalRoute> allRoutes = [];
foreach (ClusterComponent comp in component.Cluster.Components)
{
foreach (ExternalRoute r in comp.ExternalRoutes)
r.Component = comp;
allRoutes.AddRange(comp.ExternalRoutes);
}
if (allRoutes.Count == 0)
{
return new HelmExecutionResult { Success = true, Output = "" };
}
if (string.IsNullOrWhiteSpace(component.Cluster.Kubeconfig))
{
return new HelmExecutionResult { Success = false, Output = "No kubeconfig stored for this cluster." };
}
(string gatewayName, string gatewayNamespace) = ExternalRouteService.ResolveGateway(
component.Cluster.Components);
// Gateway resource (HTTPS listeners + HTTP redirect + per-hostname Certificates
// in the gateway namespace so the Gateway's certificateRefs can resolve them).
string gatewayYaml = ExternalRouteService.GenerateGatewayYaml(
gatewayName, gatewayNamespace, allRoutes);
// One HTTPRoute per route — TLS is terminated at the Gateway, no per-route
// Certificate needed here.
IEnumerable<string> httpRoutes = allRoutes.Select(ExternalRouteService.GenerateHttpRouteYaml);
string combinedYaml = string.Join("\n---\n", new[] { gatewayYaml }.Concat(httpRoutes));
string tempKubeconfig = Path.Combine(Path.GetTempPath(), $"entkube-{Guid.NewGuid()}.kubeconfig");
string tempManifest = Path.Combine(Path.GetTempPath(), $"entkube-routes-{Guid.NewGuid()}.yaml");
try
{
await File.WriteAllTextAsync(tempKubeconfig, component.Cluster.Kubeconfig, ct);
await File.WriteAllTextAsync(tempManifest, combinedYaml, ct);
return await RunProcessAsync("kubectl", $"apply -f {tempManifest} --kubeconfig {tempKubeconfig}", ct);
}
finally
{
if (File.Exists(tempKubeconfig)) File.Delete(tempKubeconfig);
if (File.Exists(tempManifest)) File.Delete(tempManifest);
}
}
private async Task<HelmExecutionResult> ExecuteKubectlUrlAsync(
HelmCommand command, string kubeconfigPath, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(command.ManifestUrl))
{
return new HelmExecutionResult
{
Success = false,
Output = "No manifest URL configured for this component."
};
}
string arguments = $"apply -f {command.ManifestUrl} --kubeconfig {kubeconfigPath}";
return await RunProcessAsync("kubectl", arguments, ct);
}
/// <summary>
/// Executes a Helm CLI command — handles repo add, upgrade --install, or uninstall.
/// When no repo URL is configured for an existing release, extracts the chart
@@ -836,9 +1114,12 @@ public class ComponentLifecycleService(IDbContextFactory<ApplicationDbContext> d
args.Add("--kubeconfig");
args.Add(kubeconfigPath);
args.Add("--wait");
args.Add("--timeout");
args.Add("5m0s");
if (!command.NoWait)
{
args.Add("--wait");
args.Add("--timeout");
args.Add("10m0s");
}
// If there's a repo URL, add the repo first and resolve the chart reference.
@@ -1101,11 +1382,17 @@ public class ComponentLifecycleService(IDbContextFactory<ApplicationDbContext> d
string stdout = await stdoutTask;
string stderr = await stderrTask;
// Always combine stdout and stderr so errors aren't silently dropped.
// helm prints informational messages (e.g. "Installing it now.") to stdout
// and errors (timeouts, render failures) to stderr. If we only show stdout,
// the real failure reason is invisible.
string combined = (stdout.Trim() + (string.IsNullOrWhiteSpace(stderr) ? "" : "\n" + stderr.Trim())).Trim();
return new HelmExecutionResult
{
Success = process.ExitCode == 0,
ExitCode = process.ExitCode,
Output = string.IsNullOrWhiteSpace(stdout) ? stderr : stdout
Output = combined
};
}
catch (Exception ex)

View File

@@ -28,6 +28,13 @@ public class DiscoveredHelmRelease
/// True if a ClusterComponent with this name already exists on the cluster.
/// </summary>
public bool AlreadyTracked { get; set; }
/// <summary>
/// If this release is a known subchart of another component, the parent
/// catalog key is stored here (e.g. "cloudnative-pg" for plugin-barman-cloud).
/// The UI can use this to hide subcharts or group them with their parent.
/// </summary>
public string? ParentComponentKey { get; set; }
}
/// <summary>
@@ -109,12 +116,24 @@ public class ComponentScanService(IDbContextFactory<ApplicationDbContext> dbFact
// Get existing components on this cluster to mark already-tracked ones.
List<ClusterComponent> existingComponents = await vaultService.GetComponentsAsync(cluster.Id, ct);
HashSet<string> trackedNames = existingComponents
.Select(c => c.Name.ToLowerInvariant())
.ToHashSet();
// Include both Name and ReleaseName so that components registered via the
// catalog (where Name = catalog key, e.g. "cloudnative-pg") are still
// matched when the Helm release name differs (e.g. "cnpg").
HashSet<string> trackedNames = new(StringComparer.OrdinalIgnoreCase);
foreach (ClusterComponent c in existingComponents)
{
trackedNames.Add(c.Name);
if (!string.IsNullOrEmpty(c.ReleaseName))
{
trackedNames.Add(c.ReleaseName);
}
}
// Decode each latest-revision release secret into a DiscoveredHelmRelease.
Dictionary<string, string> subchartParents = ComponentCatalog.GetSubchartParents();
foreach (KeyValuePair<string, (V1Secret secret, int revision)> entry in latestByRelease)
{
(V1Secret secret, int revision) = entry.Value;
@@ -122,7 +141,15 @@ public class ComponentScanService(IDbContextFactory<ApplicationDbContext> dbFact
if (release is not null)
{
release.AlreadyTracked = trackedNames.Contains(release.Name.ToLowerInvariant());
release.AlreadyTracked = trackedNames.Contains(release.Name);
// If this release is a known subchart, tag it with its parent key.
if (subchartParents.TryGetValue(release.Name, out string? parentKey))
{
release.ParentComponentKey = parentKey;
}
releases.Add(release);
}
}
@@ -244,7 +271,7 @@ public class ComponentScanService(IDbContextFactory<ApplicationDbContext> dbFact
int imported = 0;
List<string> errors = [];
foreach (DiscoveredHelmRelease release in releases.Where(r => !r.AlreadyTracked))
foreach (DiscoveredHelmRelease release in releases.Where(r => !r.AlreadyTracked && r.ParentComponentKey is null))
{
try
{
@@ -670,12 +697,17 @@ public class ComponentScanService(IDbContextFactory<ApplicationDbContext> dbFact
/// <summary>
/// Decodes a Helm release secret into a DiscoveredHelmRelease.
/// The encoding chain is: K8s base64 → Helm base64 → gzip → JSON.
/// Falls back to label-based discovery if data decoding fails.
/// </summary>
private static DiscoveredHelmRelease? DecodeHelmRelease(V1Secret secret, int revision)
{
// If the secret data is missing or doesn't have the "release" key,
// fall back to building a minimal release from the secret's labels.
// This can happen when RBAC strips data or storage format differs.
if (secret.Data is null || !secret.Data.TryGetValue("release", out byte[]? rawData))
{
return null;
return BuildFallbackRelease(secret, revision);
}
try
@@ -684,15 +716,29 @@ public class ComponentScanService(IDbContextFactory<ApplicationDbContext> dbFact
// Step 2: Helm base64-encodes the release data before storing.
string helmBase64 = Encoding.UTF8.GetString(rawData);
byte[] gzipped = Convert.FromBase64String(helmBase64);
byte[] compressed = Convert.FromBase64String(helmBase64);
// Step 3: Decompress gzip.
// Step 3: Decompress. Helm uses gzip by default, but newer versions
// or custom builds may use zstd (magic bytes 0x28 0xB5 0x2F 0xFD).
// Detect by the first two bytes (gzip magic: 0x1F 0x8B).
using MemoryStream compressedStream = new(gzipped);
using GZipStream gzipStream = new(compressedStream, CompressionMode.Decompress);
using MemoryStream decompressedStream = new();
gzipStream.CopyTo(decompressedStream);
byte[] jsonBytes = decompressedStream.ToArray();
byte[] jsonBytes;
if (compressed.Length >= 2 && compressed[0] == 0x1F && compressed[1] == 0x8B)
{
// Standard gzip compression.
using MemoryStream compressedStream = new(compressed);
using GZipStream gzipStream = new(compressedStream, CompressionMode.Decompress);
using MemoryStream decompressedStream = new();
gzipStream.CopyTo(decompressedStream);
jsonBytes = decompressedStream.ToArray();
}
else
{
// Not gzip — try interpreting as raw JSON (some Helm drivers
// store uncompressed data), or fall back to labels.
jsonBytes = compressed;
}
// Step 4: Parse JSON release object.
@@ -773,19 +819,34 @@ public class ComponentScanService(IDbContextFactory<ApplicationDbContext> dbFact
{
// If decoding fails for any reason, build a minimal release from labels.
string? fallbackName = secret.Metadata?.Labels?.TryGetValue("name", out string? fn) == true ? fn : null;
string? fallbackStatus = secret.Metadata?.Labels?.TryGetValue("status", out string? fs) == true ? fs : null;
return new DiscoveredHelmRelease
{
Name = fallbackName ?? "unknown",
Namespace = secret.Metadata?.NamespaceProperty ?? "default",
Status = fallbackStatus,
Revision = revision
};
return BuildFallbackRelease(secret, revision);
}
}
/// <summary>
/// Builds a minimal DiscoveredHelmRelease from the secret's labels when full
/// decoding isn't possible. This ensures the release still appears in scan
/// results even if data decoding fails.
/// </summary>
private static DiscoveredHelmRelease? BuildFallbackRelease(V1Secret secret, int revision)
{
string? fallbackName = secret.Metadata?.Labels?.TryGetValue("name", out string? fn) == true ? fn : null;
string? fallbackStatus = secret.Metadata?.Labels?.TryGetValue("status", out string? fs) == true ? fs : null;
if (fallbackName is null)
{
return null;
}
return new DiscoveredHelmRelease
{
Name = fallbackName,
Namespace = secret.Metadata?.NamespaceProperty ?? "default",
Status = fallbackStatus,
Revision = revision
};
}
private static ComponentStatus MapStatus(string? helmStatus)
{
return helmStatus?.ToLowerInvariant() switch
@@ -847,6 +908,60 @@ public class ComponentScanService(IDbContextFactory<ApplicationDbContext> dbFact
}
}
/// <summary>
/// Checks whether the Gateway API CRDs (httproutes.gateway.networking.k8s.io) are
/// installed on the cluster. Returns false if unreachable or CRDs are absent.
/// </summary>
public async Task<bool> CheckGatewayApiCrdsAsync(
KubernetesCluster cluster, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(cluster.Kubeconfig))
{
return false;
}
try
{
Kubernetes client = CreateClient(cluster.Kubeconfig);
await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
"httproutes.gateway.networking.k8s.io", cancellationToken: ct);
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Checks whether cert-manager has been started with Gateway API support enabled.
/// Returns false if cert-manager is not installed or does not have the feature gate.
/// </summary>
public async Task<bool> CheckCertManagerGatewayApiAsync(
KubernetesCluster cluster, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(cluster.Kubeconfig))
{
return false;
}
try
{
Kubernetes client = CreateClient(cluster.Kubeconfig);
k8s.Models.V1Deployment deployment = await client.AppsV1.ReadNamespacedDeploymentAsync(
"cert-manager", "cert-manager", cancellationToken: ct);
return deployment.Spec?.Template?.Spec?.Containers?.Any(c =>
c.Args?.Any(a =>
a.Contains("GatewayAPI=true", StringComparison.OrdinalIgnoreCase) ||
a.Contains("ExperimentalGatewayAPISupport=true", StringComparison.OrdinalIgnoreCase)) == true) == true;
}
catch
{
return false;
}
}
private static Kubernetes CreateClient(string kubeconfig)
{
using MemoryStream stream = new(Encoding.UTF8.GetBytes(kubeconfig));

View File

@@ -80,11 +80,15 @@ public class DatabaseService(IDbContextFactory<ApplicationDbContext> dbFactory)
.Include(c => c.Cluster)
.Where(c => c.Cluster.TenantId == tenantId
&& c.Status == ComponentStatus.Installed
&& (c.Name == "cloudnative-pg" || c.Name == "mongodb-operator"))
&& (c.Name == "cloudnative-pg"
|| c.Name == "mongodb-community-operator" || c.Name == "mongodb-operator"
|| c.ReleaseName == "mongodb-community-operator" || c.ReleaseName == "mongodb-operator"))
.ToListAsync(ct);
ClusterComponent? cnpg = installedComponents.FirstOrDefault(c => c.Name == "cloudnative-pg");
ClusterComponent? mongo = installedComponents.FirstOrDefault(c => c.Name == "mongodb-operator");
ClusterComponent? mongo = installedComponents.FirstOrDefault(c =>
c.Name is "mongodb-community-operator" or "mongodb-operator"
|| c.ReleaseName is "mongodb-community-operator" or "mongodb-operator");
return new DatabaseOperatorStatus
{
@@ -112,6 +116,26 @@ public class DatabaseService(IDbContextFactory<ApplicationDbContext> dbFactory)
.ToListAsync(ct);
}
/// <summary>
/// Returns all Kubernetes clusters that have the Percona MongoDB operator installed.
/// Used by the UI to populate the target cluster dropdown when creating
/// a new managed MongoDB cluster.
/// </summary>
public async Task<List<KubernetesCluster>> GetMongoEnabledClustersAsync(
Guid tenantId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
return await db.KubernetesClusters
.Where(k => k.TenantId == tenantId
&& k.Components.Any(c =>
(c.Name == "mongodb-community-operator" || c.Name == "mongodb-operator"
|| c.ReleaseName == "mongodb-community-operator" || c.ReleaseName == "mongodb-operator")
&& c.Status == ComponentStatus.Installed))
.OrderBy(k => k.Name)
.ToListAsync(ct);
}
// ──────── CNPG Discovery ────────
/// <summary>
@@ -271,9 +295,9 @@ public class DatabaseService(IDbContextFactory<ApplicationDbContext> dbFactory)
// ──────── MongoDB Discovery ────────
/// <summary>
/// Discovers all MongoDB Community resources across the tenant's clusters.
/// Reads the mongodbcommunity.mongodb.com/v1 MongoDBCommunity CRD from every
/// cluster that has the mongodb-operator installed.
/// Discovers all Percona Server for MongoDB resources across the tenant's clusters.
/// Reads the psmdb.percona.com/v1 PerconaServerMongoDB CRD from every
/// cluster that has the mongodb-community-operator installed.
/// </summary>
public async Task<List<DatabaseClusterInfo>> GetMongoDbClustersAsync(
Guid tenantId, CancellationToken ct = default)
@@ -283,7 +307,10 @@ public class DatabaseService(IDbContextFactory<ApplicationDbContext> dbFactory)
List<KubernetesCluster> clusters = await db.KubernetesClusters
.Include(k => k.Environment)
.Where(k => k.TenantId == tenantId
&& k.Components.Any(c => c.Name == "mongodb-operator" && c.Status == ComponentStatus.Installed))
&& k.Components.Any(c =>
(c.Name == "mongodb-community-operator" || c.Name == "mongodb-operator"
|| c.ReleaseName == "mongodb-community-operator" || c.ReleaseName == "mongodb-operator")
&& c.Status == ComponentStatus.Installed))
.ToListAsync(ct);
List<DatabaseClusterInfo> results = [];
@@ -351,32 +378,28 @@ public class DatabaseService(IDbContextFactory<ApplicationDbContext> dbFactory)
string name = metadata.GetProperty("name").GetString() ?? "unknown";
string ns = metadata.GetProperty("namespace").GetString() ?? "default";
int members = spec.TryGetProperty("members", out JsonElement membersEl)
? membersEl.GetInt32() : 1;
// Community Operator uses spec.members directly.
string? version = spec.TryGetProperty("version", out JsonElement verEl)
? verEl.GetString() : null;
int members = 1;
// Extract database users and their associated databases.
if (spec.TryGetProperty("members", out JsonElement membersEl))
{
members = membersEl.GetInt32();
}
// Version is in spec.version (e.g. "8.0.8").
string? version = null;
if (spec.TryGetProperty("version", out JsonElement versionEl))
{
version = versionEl.GetString();
}
// Databases are not listed in the CRD — implicit in MongoDB.
List<string> databases = [];
if (spec.TryGetProperty("users", out JsonElement users))
{
foreach (JsonElement user in users.EnumerateArray())
{
if (user.TryGetProperty("db", out JsonElement dbEl))
{
string? dbName = dbEl.GetString();
if (!string.IsNullOrEmpty(dbName) && !databases.Contains(dbName))
{
databases.Add(dbName);
}
}
}
}
// Status from the CR.
string status = "Unknown";

View File

@@ -167,6 +167,19 @@ public class ExternalRouteService(IDbContextFactory<ApplicationDbContext> dbFact
return GenerateHttpRouteYaml(route);
}
public async Task<string> GenerateFullManifestYamlAsync(
Guid routeId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
ExternalRoute route = await db.ExternalRoutes
.Include(r => r.Component)
.FirstOrDefaultAsync(r => r.Id == routeId, ct)
?? throw new InvalidOperationException("Route not found.");
return GenerateFullManifestYaml(route);
}
/// <summary>
/// Generates HTTPRoute YAML from an in-memory route object.
/// Useful for previewing before saving.
@@ -175,26 +188,9 @@ public class ExternalRouteService(IDbContextFactory<ApplicationDbContext> dbFact
{
string ns = route.Component?.Namespace ?? "default";
string routeName = $"{route.ServiceName}-route";
string secretName = $"{route.ServiceName}-tls";
// Build the annotations section based on TLS mode.
string annotations = route.TlsMode == TlsMode.ClusterIssuer
? $" cert-manager.io/cluster-issuer: \"{route.ClusterIssuerName}\""
: $" entkube.io/tls-mode: \"manual\"";
// Build the TLS section — for manual, reference a Secret that holds the cert.
string tlsSection = route.TlsMode == TlsMode.Manual
? $"""
tls:
certificateRefs:
- name: {secretName}
kind: Secret
"""
: "";
// Build path match rules.
// TLS is terminated at the Gateway listener — HTTPRoute only routes by hostname/path.
// No TLS section belongs in HTTPRoute.spec.
string pathMatch = route.PathPrefix != "/"
? $"""
@@ -212,25 +208,66 @@ public class ExternalRouteService(IDbContextFactory<ApplicationDbContext> dbFact
port: {route.ServicePort}
""";
string yaml = $"""
return $"""
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {routeName}
namespace: {ns}
annotations:
{annotations}
spec:
parentRefs:
- name: {route.GatewayName}
namespace: {route.GatewayNamespace}
{tlsSection} hostnames:
hostnames:
- {route.Hostname}
rules:
{pathMatch}
""";
}
return yaml;
/// <summary>
/// Generates a cert-manager Certificate resource for ClusterIssuer TLS mode.
/// cert-manager will provision and renew the TLS secret automatically.
/// Returns empty string for Manual TLS (user supplies the certificate).
/// </summary>
public static string GenerateCertificateYaml(ExternalRoute route)
{
if (route.TlsMode != TlsMode.ClusterIssuer || string.IsNullOrWhiteSpace(route.ClusterIssuerName))
{
return "";
}
string ns = route.Component?.Namespace ?? "default";
string secretName = $"{route.ServiceName}-tls";
return $"""
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {secretName}
namespace: {ns}
spec:
secretName: {secretName}
issuerRef:
name: {route.ClusterIssuerName}
kind: ClusterIssuer
dnsNames:
- {route.Hostname}
""";
}
/// <summary>
/// Generates the complete manifest for a route: HTTPRoute plus a Certificate
/// resource (when using ClusterIssuer TLS). Apply this single YAML to the cluster.
/// </summary>
public static string GenerateFullManifestYaml(ExternalRoute route)
{
string httpRoute = GenerateHttpRouteYaml(route);
string certificate = GenerateCertificateYaml(route);
return string.IsNullOrEmpty(certificate)
? httpRoute
: $"{httpRoute}\n---\n{certificate}";
}
/// <summary>
@@ -270,43 +307,186 @@ public class ExternalRouteService(IDbContextFactory<ApplicationDbContext> dbFact
return yaml;
}
/// <summary>
/// Generates a <c>gateway.networking.k8s.io/v1/Gateway</c> resource with one HTTPS
/// listener per unique hostname in <paramref name="routes"/>, plus an HTTP-to-HTTPS
/// redirect listener, and explicit cert-manager Certificate resources placed in
/// <paramref name="gatewayNamespace"/> so they can be referenced by the Gateway's
/// <c>certificateRefs</c> without cross-namespace ReferenceGrants.
///
/// The returned YAML contains multiple documents separated by <c>---</c>:
/// 1. The Gateway resource
/// 2. An HTTP→HTTPS redirect HTTPRoute
/// 3. One Certificate resource per ClusterIssuer-mode hostname
/// </summary>
public static string GenerateGatewayYaml(
string gatewayName,
string gatewayNamespace,
IEnumerable<ExternalRoute> routes)
{
var grouped = routes
.GroupBy(r => r.Hostname)
.Select(g => (
Hostname: g.Key,
ListenerName: ToListenerName(g.Key),
CertSecretName: ToCertSecretName(g.Key),
ClusterIssuerName: g.Select(r => r.ClusterIssuerName).FirstOrDefault(n => n != null),
IsCertIssuer: g.Any(r => r.TlsMode == TlsMode.ClusterIssuer)
))
.ToList();
IEnumerable<string> httpsListeners = grouped.Select(g =>
$" - name: {g.ListenerName}\n" +
$" hostname: {g.Hostname}\n" +
$" port: 443\n" +
$" protocol: HTTPS\n" +
$" tls:\n" +
$" mode: Terminate\n" +
$" certificateRefs:\n" +
$" - name: {g.CertSecretName}\n" +
$" allowedRoutes:\n" +
$" namespaces:\n" +
$" from: All");
const string httpListener =
" - name: http-redirect\n" +
" port: 80\n" +
" protocol: HTTP\n" +
" allowedRoutes:\n" +
" namespaces:\n" +
" from: Same";
string allListeners = string.Join("\n", httpsListeners.Append(httpListener));
// No cert-manager.io/cluster-issuer annotation on the Gateway — we create
// explicit Certificate resources below, so the gateway-shim is not needed.
// Using the annotation alongside explicit Certificates causes the gateway-shim
// to fight over the same Certificate objects, which prevents cert-manager
// from ever starting the ACME order.
string gatewayYaml =
$"apiVersion: gateway.networking.k8s.io/v1\n" +
$"kind: Gateway\n" +
$"metadata:\n" +
$" name: {gatewayName}\n" +
$" namespace: {gatewayNamespace}\n" +
$" annotations:\n" +
$" app.kubernetes.io/managed-by: entkube\n" +
$"spec:\n" +
$" gatewayClassName: istio\n" +
$" addresses:\n" +
$" - type: Hostname\n" +
$" value: {gatewayName}.{gatewayNamespace}.svc.cluster.local\n" +
$" listeners:\n" +
allListeners;
string httpRedirectRoute =
$"apiVersion: gateway.networking.k8s.io/v1\n" +
$"kind: HTTPRoute\n" +
$"metadata:\n" +
$" name: http-to-https-redirect\n" +
$" namespace: {gatewayNamespace}\n" +
$"spec:\n" +
$" parentRefs:\n" +
$" - name: {gatewayName}\n" +
$" namespace: {gatewayNamespace}\n" +
$" sectionName: http-redirect\n" +
$" rules:\n" +
$" - filters:\n" +
$" - type: RequestRedirect\n" +
$" requestRedirect:\n" +
$" scheme: https\n" +
$" statusCode: 301";
List<string> parts = [gatewayYaml, httpRedirectRoute];
foreach (var g in grouped.Where(g => g.IsCertIssuer && !string.IsNullOrWhiteSpace(g.ClusterIssuerName)))
{
parts.Add(
$"apiVersion: cert-manager.io/v1\n" +
$"kind: Certificate\n" +
$"metadata:\n" +
$" name: {g.CertSecretName}\n" +
$" namespace: {gatewayNamespace}\n" +
$"spec:\n" +
$" secretName: {g.CertSecretName}\n" +
$" issuerRef:\n" +
$" name: {g.ClusterIssuerName}\n" +
$" kind: ClusterIssuer\n" +
$" dnsNames:\n" +
$" - {g.Hostname}");
}
return string.Join("\n---\n", parts);
}
/// <summary>
/// Sanitizes a hostname into a valid Kubernetes resource name / listener name
/// by replacing non-alphanumeric characters with dashes, trimming edge dashes,
/// and capping at 63 characters (DNS label limit).
/// </summary>
private static string ToListenerName(string hostname)
{
string sanitized = new string(hostname.ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) ? c : '-')
.ToArray())
.Trim('-');
return sanitized.Length > 63 ? sanitized[..63] : sanitized;
}
/// <summary>Derives the TLS secret name from a hostname (used in both the Gateway listener and Certificate).</summary>
public static string ToCertSecretName(string hostname) => ToListenerName(hostname) + "-tls";
// ── Private helpers ──
/// <summary>
/// Determines the Gateway resource name based on which ingress controller
/// is installed on the cluster. Traefik creates "traefik-gateway", Istio
/// uses its own naming convention.
/// Returns the (gatewayName, gatewayNamespace) for the ingress controller installed
/// on the cluster. Checks both by component Name and by ReleaseName/HelmChartName
/// to handle imported components and custom release names.
/// </summary>
private static string ResolveGatewayName(IEnumerable<ClusterComponent> components)
public static (string Name, string Namespace) ResolveGateway(IEnumerable<ClusterComponent> components)
{
if (components.Any(c => string.Equals(c.Name, "traefik", StringComparison.OrdinalIgnoreCase)))
List<ClusterComponent> list = components.ToList();
bool hasTraefik = list.Any(c =>
string.Equals(c.Name, "traefik", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.HelmChartName, "traefik", StringComparison.OrdinalIgnoreCase));
if (hasTraefik)
{
return "traefik-gateway";
return ("traefik-gateway", "traefik");
}
if (components.Any(c => string.Equals(c.Name, "istio", StringComparison.OrdinalIgnoreCase)))
ClusterComponent? istio = list.FirstOrDefault(c =>
string.Equals(c.Name, "istio", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.HelmChartName, "gateway", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(c.Namespace, "istio-system", StringComparison.OrdinalIgnoreCase)));
if (istio is not null)
{
return "istio-ingress";
string name = istio.ReleaseName ?? istio.Name;
return (name, istio.Namespace ?? "istio-system");
}
return "default-gateway";
return ("default-gateway", "default");
}
/// <summary>
/// Determines the Gateway namespace based on the installed ingress controller.
/// Returns the Kubernetes ingressClassName for cert-manager HTTP-01 ACME challenges.
/// Uses standard Ingress (not gatewayHTTPRoute) so no cert-manager experimental
/// feature gates are needed. Istio handles ingressClassName "istio" natively.
/// </summary>
private static string ResolveGatewayNamespace(IEnumerable<ClusterComponent> components)
public static string ResolveIngressClass(IEnumerable<ClusterComponent> components)
{
if (components.Any(c => string.Equals(c.Name, "traefik", StringComparison.OrdinalIgnoreCase)))
{
return "traefik";
}
bool hasTraefik = components.Any(c =>
string.Equals(c.Name, "traefik", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.HelmChartName, "traefik", StringComparison.OrdinalIgnoreCase));
if (components.Any(c => string.Equals(c.Name, "istio", StringComparison.OrdinalIgnoreCase)))
{
return "istio-system";
}
return "default";
return hasTraefik ? "traefik" : "istio";
}
private static string ResolveGatewayName(IEnumerable<ClusterComponent> components) =>
ResolveGateway(components).Name;
private static string ResolveGatewayNamespace(IEnumerable<ClusterComponent> components) =>
ResolveGateway(components).Namespace;
}

View File

@@ -38,4 +38,19 @@ public interface IKubernetesClientFactory
/// Uses kubectl exec to connect to the primary and run psql.
/// </summary>
Task ExecuteSqlAsync(string clusterName, string ns, string sql, string kubeconfig, CancellationToken ct = default);
/// <summary>
/// Executes a MongoDB script against the primary pod via kubectl exec + mongosh.
/// The primary pod is the first StatefulSet member: {clusterName}-0.
/// When username and password are provided, mongosh connects with SCRAM credentials.
/// </summary>
Task ExecuteMongoAsync(string clusterName, string ns, string script, string kubeconfig,
string? username = null, string? password = null, CancellationToken ct = default);
/// <summary>
/// Reads a single key from a Kubernetes Secret and returns the decoded value.
/// Returns null if the secret or key does not exist.
/// </summary>
Task<string?> GetSecretValueAsync(string secretName, string key, string ns, string kubeconfig,
CancellationToken ct = default);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
namespace EntKube.Web.Services;
@@ -103,11 +104,12 @@ public class KubernetesClientFactory : IKubernetesClientFactory
// CNPG primary pod follows the naming pattern: {cluster}-1
string primaryPod = $"{clusterName}-1";
// Escape single quotes in SQL for shell safety.
string escapedSql = sql.Replace("'", "'\\''");
// Pipe SQL via stdin to avoid shell argument splitting issues.
// The -i flag tells kubectl exec to pass stdin to the container.
await RunKubectlAsync(
$"exec {primaryPod} -n {ns} --kubeconfig={kubeconfigPath} -- psql -U postgres -c '{escapedSql}'", ct);
await RunKubectlWithStdinAsync(
$"exec -i {primaryPod} -n {ns} --kubeconfig={kubeconfigPath} -- psql -U postgres",
sql, ct);
}
finally
{
@@ -115,6 +117,58 @@ public class KubernetesClientFactory : IKubernetesClientFactory
}
}
public async Task ExecuteMongoAsync(
string clusterName, string ns, string script, string kubeconfig,
string? username = null, string? password = null, CancellationToken ct = default)
{
string kubeconfigPath = Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(kubeconfigPath, kubeconfig, ct);
// MongoDB Community Operator names StatefulSet pods {cluster}-0, {cluster}-1, ...
string primaryPod = $"{clusterName}-0";
// When credentials are provided, authenticate with SCRAM.
// Passwords generated by MongoService are alphanumeric-safe (no shell escaping needed).
string mongoshArgs = !string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)
? $"--username {username} --password {password} --authenticationDatabase admin --quiet"
: "--quiet";
await RunKubectlWithStdinAsync(
$"exec -i {primaryPod} -n {ns} --kubeconfig={kubeconfigPath} -- mongosh {mongoshArgs}",
script, ct);
}
finally
{
File.Delete(kubeconfigPath);
}
}
public async Task<string?> GetSecretValueAsync(
string secretName, string key, string ns, string kubeconfig, CancellationToken ct = default)
{
try
{
string json = await GetJsonAsync($"secret/{secretName}", ns, kubeconfig, ct: ct);
using JsonDocument doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("data", out JsonElement data)
&& data.TryGetProperty(key, out JsonElement valEl))
{
string? b64 = valEl.GetString();
if (string.IsNullOrEmpty(b64)) return null;
return Encoding.UTF8.GetString(Convert.FromBase64String(b64));
}
return null;
}
catch
{
return null;
}
}
private static async Task<string> RunKubectlAsync(string arguments, CancellationToken ct)
{
using Process process = new()
@@ -151,4 +205,51 @@ public class KubernetesClientFactory : IKubernetesClientFactory
return output.ToString();
}
/// <summary>
/// Runs kubectl with input piped via stdin. Used for executing SQL/scripts
/// where passing the content as command-line arguments would break due to
/// shell escaping and argument splitting in Process.
/// </summary>
private static async Task<string> RunKubectlWithStdinAsync(
string arguments, string stdinContent, CancellationToken ct)
{
using Process process = new()
{
StartInfo = new ProcessStartInfo
{
FileName = "kubectl",
Arguments = arguments,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
// Write the script/SQL to stdin and close it so the process knows input is done.
await process.StandardInput.WriteAsync(stdinContent.AsMemory(), ct);
await process.StandardInput.FlushAsync(ct);
process.StandardInput.Close();
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(ct);
Task<string> errorTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
string output = await outputTask;
string error = await errorTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"kubectl failed (exit {process.ExitCode}): {error}");
}
return output;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -79,7 +79,7 @@ public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory ht
// Step 2: Authenticate against Keystone to get a scoped token, user ID, and project ID.
(string token, string userId, string projectId) = await AuthenticateKeystoneAsync(connection, password, ct);
(string token, string userId, string projectId, _) = await AuthenticateKeystoneAsync(connection, password, ct);
// Step 3: Create EC2 credentials for S3 access.
@@ -121,7 +121,7 @@ public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory ht
/// Authenticates against OpenStack Keystone v3 using password auth.
/// Returns the scoped auth token and the user ID (needed for EC2 credential creation).
/// </summary>
private async Task<(string Token, string UserId, string ProjectId)> AuthenticateKeystoneAsync(
private async Task<(string Token, string UserId, string ProjectId, string? SwiftEndpoint)> AuthenticateKeystoneAsync(
OpenStackConnection connection, string password, CancellationToken ct)
{
// Build the Keystone v3 auth request body.
@@ -185,10 +185,10 @@ public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory ht
string token = response.Headers.GetValues("X-Subject-Token").First();
// Extract the user ID and project ID from the response body.
// The project ID here is always a UUID, even if we authenticated by project name.
// Extract the user ID, project ID, and Swift endpoint from the response body.
using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
string body = await response.Content.ReadAsStringAsync(ct);
using JsonDocument doc = JsonDocument.Parse(body);
JsonElement tokenElement = doc.RootElement.GetProperty("token");
string userId = tokenElement
@@ -201,7 +201,104 @@ public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory ht
.GetProperty("id")
.GetString()!;
return (token, userId, projectId);
// Find the public Swift (object-store) endpoint from the service catalog.
// This lets us list all containers regardless of which EC2 credential pair was used.
string? swiftEndpoint = null;
if (tokenElement.TryGetProperty("catalog", out JsonElement catalog))
{
foreach (JsonElement service in catalog.EnumerateArray())
{
if (service.TryGetProperty("type", out JsonElement typeEl)
&& typeEl.GetString() == "object-store"
&& service.TryGetProperty("endpoints", out JsonElement endpoints))
{
foreach (JsonElement ep in endpoints.EnumerateArray())
{
if (ep.TryGetProperty("interface", out JsonElement iface)
&& iface.GetString() == "public"
&& ep.TryGetProperty("url", out JsonElement urlEl))
{
swiftEndpoint = urlEl.GetString();
break;
}
}
if (swiftEndpoint is not null)
{
break;
}
}
}
}
return (token, userId, projectId, swiftEndpoint);
}
/// <summary>
/// Lists all containers in the OpenStack project via the Swift REST API.
/// More reliable than S3 ListBuckets against Ceph RADOS Gateway because it
/// goes through the project-scoped object-store endpoint rather than the
/// per-EC2-credential RGW user scope.
/// </summary>
public async Task<List<S3BucketInfo>> ListBucketsViaSwiftAsync(
OpenStackConnection connection, string password, CancellationToken ct = default)
{
(string token, _, _, string? swiftEndpoint) = await AuthenticateKeystoneAsync(connection, password, ct);
if (swiftEndpoint is null)
{
throw new InvalidOperationException(
"No public Swift (object-store) endpoint found in the Keystone service catalog.");
}
// GET <swift_endpoint>?format=json lists all containers owned by this project.
using HttpClient client = httpClientFactory.CreateClient();
string listUrl = swiftEndpoint.TrimEnd('/') + "?format=json";
using HttpRequestMessage listRequest = new(HttpMethod.Get, listUrl);
listRequest.Headers.Add("X-Auth-Token", token);
using HttpResponseMessage listResponse = await client.SendAsync(listRequest, ct);
if (!listResponse.IsSuccessStatusCode)
{
string errorBody = await listResponse.Content.ReadAsStringAsync(ct);
throw new InvalidOperationException(
$"Swift container listing failed ({listResponse.StatusCode}): {errorBody}");
}
string responseBody = await listResponse.Content.ReadAsStringAsync(ct);
if (string.IsNullOrWhiteSpace(responseBody) || responseBody == "[]")
{
return [];
}
using JsonDocument doc = JsonDocument.Parse(responseBody);
return doc.RootElement.EnumerateArray()
.Select(container =>
{
string name = container.TryGetProperty("name", out JsonElement nameEl)
? nameEl.GetString() ?? ""
: "";
DateTime lastModified = default;
if (container.TryGetProperty("last_modified", out JsonElement lmEl)
&& DateTime.TryParse(lmEl.GetString(), out DateTime parsed))
{
lastModified = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
return new S3BucketInfo { Name = name, CreatedAt = lastModified };
})
.Where(b => b.Name.Length > 0)
.ToList();
}
/// <summary>
@@ -335,8 +432,8 @@ public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory ht
ListBucketsResponse response = await s3Client.ListBucketsAsync(ct);
return response.Buckets
.Select(b => new S3BucketInfo { Name = b.BucketName, CreatedAt = b.CreationDate.GetValueOrDefault() })
return (response.Buckets ?? [])
.Select(b => new S3BucketInfo { Name = b.BucketName ?? "", CreatedAt = b.CreationDate.GetValueOrDefault() })
.ToList();
}
@@ -352,36 +449,47 @@ public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory ht
// Empty the bucket first — S3 won't delete non-empty buckets.
// We page through all objects and delete them in batches of up to 1000.
// If the bucket doesn't exist at all we treat it as already gone.
string? continuationToken = null;
do
try
{
ListObjectsV2Request listRequest = new()
{
BucketName = bucketName,
MaxKeys = 1000,
ContinuationToken = continuationToken
};
string? continuationToken = null;
ListObjectsV2Response listResponse = await s3Client.ListObjectsV2Async(listRequest, ct);
if (listResponse.S3Objects.Count > 0)
do
{
DeleteObjectsRequest deleteObjectsRequest = new()
ListObjectsV2Request listRequest = new()
{
BucketName = bucketName,
Objects = listResponse.S3Objects
.Select(o => new KeyVersion { Key = o.Key })
.ToList()
MaxKeys = 1000,
ContinuationToken = continuationToken
};
await s3Client.DeleteObjectsAsync(deleteObjectsRequest, ct);
}
ListObjectsV2Response listResponse = await s3Client.ListObjectsV2Async(listRequest, ct);
continuationToken = listResponse.IsTruncated == true ? listResponse.NextContinuationToken : null;
List<S3Object> objects = listResponse.S3Objects ?? [];
if (objects.Count > 0)
{
DeleteObjectsRequest deleteObjectsRequest = new()
{
BucketName = bucketName,
Objects = objects
.Select(o => new KeyVersion { Key = o.Key ?? "" })
.ToList()
};
await s3Client.DeleteObjectsAsync(deleteObjectsRequest, ct);
}
continuationToken = listResponse.IsTruncated == true ? listResponse.NextContinuationToken : null;
}
while (continuationToken is not null);
}
catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchBucket")
{
// Bucket was already deleted outside of EntKube — nothing left to empty.
return;
}
while (continuationToken is not null);
// Remove the bucket policy before deletion. Ceph RADOS Gateway (used by Cleura)
// may reject DeleteBucket if a policy is still attached.
@@ -395,8 +503,15 @@ public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory ht
// No policy to remove — that's fine, proceed with deletion.
}
DeleteBucketRequest deleteRequest = new() { BucketName = bucketName };
await s3Client.DeleteBucketAsync(deleteRequest, ct);
try
{
DeleteBucketRequest deleteRequest = new() { BucketName = bucketName };
await s3Client.DeleteBucketAsync(deleteRequest, ct);
}
catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchBucket")
{
// Already gone — nothing to do.
}
}
/// <summary>
@@ -544,7 +659,7 @@ public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory ht
// Authenticate and create fresh EC2 credentials.
(string token, string userId, string projectId) = await AuthenticateKeystoneAsync(connection, password, ct);
(string token, string userId, string projectId, _) = await AuthenticateKeystoneAsync(connection, password, ct);
(string newAccessKey, string newSecretKey) = await CreateEc2CredentialsAsync(token, userId, projectId, connection.AuthUrl, ct);
// Verify the new credentials work by listing the bucket.
@@ -574,6 +689,11 @@ public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory ht
/// </summary>
private static AmazonS3Client CreateS3Client(string endpoint, string accessKey, string secretKey, string region)
{
if (string.IsNullOrEmpty(endpoint))
{
throw new InvalidOperationException("S3 endpoint is required but was not configured for this storage link.");
}
AmazonS3Config config = new()
{
ServiceURL = endpoint,

View File

@@ -392,10 +392,36 @@ public class StorageService(IDbContextFactory<ApplicationDbContext> dbFactory, V
Guid tenantId, Guid linkId, CancellationToken ct = default)
{
StorageLink link = await GetCleuraLinkOrThrowAsync(tenantId, linkId, ct);
// Prefer the Swift API (via the stored OpenStack connection) because it
// enumerates all project containers, whereas S3 ListBuckets scopes to the
// specific EC2 credential's RGW user and often returns an empty list.
if (link.OpenStackConnectionId.HasValue)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
OpenStackConnection? connection = await db.OpenStackConnections
.FirstOrDefaultAsync(c => c.Id == link.OpenStackConnectionId.Value, ct);
if (connection is not null)
{
string? password = await vaultService.GetOpenStackSecretValueAsync(
tenantId, connection.Id, "OS_PASSWORD", ct);
if (!string.IsNullOrEmpty(password))
{
return await openStackS3.ListBucketsViaSwiftAsync(connection, password, ct);
}
}
}
// Fallback: use the stored EC2 credentials with the S3 API.
(string accessKey, string secretKey) = await GetStoredCredentialsAsync(tenantId, linkId, ct);
return await openStackS3.ListBucketsAsync(
link.Endpoint!, accessKey, secretKey, link.Region!, ct);
link.Endpoint ?? "", accessKey, secretKey, link.Region ?? "", ct);
}
/// <summary>
@@ -407,12 +433,20 @@ public class StorageService(IDbContextFactory<ApplicationDbContext> dbFactory, V
Guid tenantId, Guid linkId, CancellationToken ct = default)
{
StorageLink link = await GetCleuraLinkOrThrowAsync(tenantId, linkId, ct);
(string accessKey, string secretKey) = await GetStoredCredentialsAsync(tenantId, linkId, ct);
// Delete the actual bucket from the object store.
// Only attempt S3 deletion when we have all the connection details.
// If any field is missing the bucket may not exist (or was never fully provisioned),
// so we fall through and clean up the DB record regardless.
await openStackS3.DeleteBucketAsync(
link.Endpoint!, accessKey, secretKey, link.BucketName!, link.Region!, ct);
if (!string.IsNullOrEmpty(link.Endpoint)
&& !string.IsNullOrEmpty(link.BucketName)
&& !string.IsNullOrEmpty(link.Region))
{
(string accessKey, string secretKey) = await GetStoredCredentialsAsync(tenantId, linkId, ct);
await openStackS3.DeleteBucketAsync(
link.Endpoint, accessKey, secretKey, link.BucketName, link.Region, ct);
}
// Remove the StorageLink and vault secrets.

View File

@@ -144,10 +144,13 @@ public class VaultService(IDbContextFactory<ApplicationDbContext> dbFactory, Vau
// --- Component Secrets ---
/// <summary>
/// Stores or updates a secret for a cluster component.
/// Stores or updates a secret for a cluster component. When k8sSecretName is
/// provided, the vault sync service will mirror the value into that Kubernetes Secret.
/// </summary>
public async Task<VaultSecret> SetComponentSecretAsync(
Guid tenantId, Guid componentId, string name, string value, CancellationToken ct = default)
Guid tenantId, Guid componentId, string name, string value,
CancellationToken ct = default,
string? k8sSecretName = null, string? k8sNamespace = null)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
@@ -162,6 +165,12 @@ public class VaultService(IDbContextFactory<ApplicationDbContext> dbFactory, Vau
{
existing.EncryptedValue = ciphertext;
existing.Nonce = nonce;
if (k8sSecretName is not null)
{
existing.SyncToKubernetes = true;
existing.KubernetesSecretName = k8sSecretName;
existing.KubernetesNamespace = k8sNamespace;
}
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return existing;
@@ -176,7 +185,10 @@ public class VaultService(IDbContextFactory<ApplicationDbContext> dbFactory, Vau
Name = name,
EncryptedValue = ciphertext,
Nonce = nonce,
ComponentId = componentId
ComponentId = componentId,
SyncToKubernetes = k8sSecretName is not null,
KubernetesSecretName = k8sSecretName,
KubernetesNamespace = k8sNamespace
};
db.Set<VaultSecret>().Add(secret);
@@ -439,6 +451,144 @@ public class VaultService(IDbContextFactory<ApplicationDbContext> dbFactory, Vau
.ToListAsync(ct);
}
/// <summary>
/// Returns the decrypted password for a CNPG database. Used when another
/// service (e.g. Keycloak) needs to read the DB password to propagate it.
/// </summary>
public async Task<string?> GetCnpgDatabasePasswordAsync(
Guid tenantId, Guid cnpgDatabaseId, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
VaultSecret? secret = await db.Set<VaultSecret>()
.FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId
&& s.CnpgDatabaseId == cnpgDatabaseId
&& s.Name == "PASSWORD", ct);
if (secret is null) return null;
byte[] dataKey = await UnsealVaultAsync(tenantId, ct);
return encryption.Decrypt(dataKey, secret.EncryptedValue, secret.Nonce);
}
/// <summary>
/// Creates or updates a vault secret scoped to a MongoDB database.
/// Mirrors SetCnpgDatabaseSecretAsync but uses MongoDatabaseId.
/// </summary>
public async Task<VaultSecret> SetMongoDatabaseSecretAsync(
Guid tenantId, Guid mongoDatabaseId, string name, string value,
string k8sSecretName, string k8sNamespace, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
byte[] dataKey = await UnsealVaultAsync(tenantId, ct);
VaultSecret? existing = await db.Set<VaultSecret>()
.FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.MongoDatabaseId == mongoDatabaseId && s.Name == name, ct);
(byte[] ciphertext, byte[] nonce) = encryption.Encrypt(dataKey, value);
if (existing is not null)
{
existing.EncryptedValue = ciphertext;
existing.Nonce = nonce;
existing.SyncToKubernetes = true;
existing.KubernetesSecretName = k8sSecretName;
existing.KubernetesNamespace = k8sNamespace;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return existing;
}
SecretVault vault = (await GetVaultAsync(tenantId, ct))!;
VaultSecret secret = new()
{
Id = Guid.NewGuid(),
VaultId = vault.Id,
Name = name,
EncryptedValue = ciphertext,
Nonce = nonce,
MongoDatabaseId = mongoDatabaseId,
SyncToKubernetes = true,
KubernetesSecretName = k8sSecretName,
KubernetesNamespace = k8sNamespace
};
db.Set<VaultSecret>().Add(secret);
await db.SaveChangesAsync(ct);
return secret;
}
/// <summary>
/// Creates or updates a vault secret scoped to a managed MongoDB cluster.
/// When SyncToKubernetes is true the vault sync service will keep the K8s Secret
/// up to date, but callers that need the Secret immediately should also apply
/// the manifest directly via IKubernetesClientFactory.
/// </summary>
public async Task<VaultSecret> SetMongoClusterSecretAsync(
Guid tenantId, Guid mongoClusterId, string name, string value,
string k8sSecretName, string k8sNamespace, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
byte[] dataKey = await UnsealVaultAsync(tenantId, ct);
VaultSecret? existing = await db.Set<VaultSecret>()
.FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.MongoClusterId == mongoClusterId && s.Name == name, ct);
(byte[] ciphertext, byte[] nonce) = encryption.Encrypt(dataKey, value);
if (existing is not null)
{
existing.EncryptedValue = ciphertext;
existing.Nonce = nonce;
existing.SyncToKubernetes = true;
existing.KubernetesSecretName = k8sSecretName;
existing.KubernetesNamespace = k8sNamespace;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return existing;
}
SecretVault vault = (await GetVaultAsync(tenantId, ct))!;
VaultSecret secret = new()
{
Id = Guid.NewGuid(),
VaultId = vault.Id,
Name = name,
EncryptedValue = ciphertext,
Nonce = nonce,
MongoClusterId = mongoClusterId,
SyncToKubernetes = true,
KubernetesSecretName = k8sSecretName,
KubernetesNamespace = k8sNamespace
};
db.Set<VaultSecret>().Add(secret);
await db.SaveChangesAsync(ct);
return secret;
}
/// <summary>
/// Decrypts and returns the value of a secret scoped to a managed MongoDB cluster.
/// Returns null if no matching secret is found.
/// </summary>
public async Task<string?> GetMongoClusterSecretValueAsync(
Guid tenantId, Guid mongoClusterId, string name, CancellationToken ct = default)
{
using ApplicationDbContext db = dbFactory.CreateDbContext();
VaultSecret? secret = await db.Set<VaultSecret>()
.FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.MongoClusterId == mongoClusterId && s.Name == name, ct);
if (secret is null) return null;
byte[] dataKey = await UnsealVaultAsync(tenantId, ct);
return encryption.Decrypt(dataKey, secret.EncryptedValue, secret.Nonce);
}
// --- Secret Management ---
/// <summary>

View File

@@ -55,6 +55,35 @@ public class ComponentFormField
/// Defaults to the field Key if not specified.
/// </summary>
public string? SecretName { get; init; }
/// <summary>
/// When set, this field is only shown when the sibling field with this key
/// has the value specified in <see cref="DependsOnValue"/>.
/// Used for conditional fields (e.g. show cert/key inputs only when TLS mode is Manual).
/// </summary>
public string? DependsOnKey { get; init; }
/// <summary>The value that <see cref="DependsOnKey"/> must equal for this field to be visible.</summary>
public string? DependsOnValue { get; init; }
/// <summary>
/// When StoreAsSecret is true, automatically sync this vault secret to a Kubernetes Secret
/// with this name. The vault secret name becomes the key inside the K8s Secret.
/// Useful for secrets that external resources (like cert-manager ClusterIssuers) reference directly.
/// </summary>
public string? KubernetesSecretName { get; init; }
/// <summary>
/// Namespace for the auto-synced Kubernetes Secret. Defaults to the component's namespace.
/// </summary>
public string? KubernetesSecretNamespace { get; init; }
/// <summary>
/// For subchart toggle fields (YamlPath starting with "subchart:"), the Helm values YAML
/// to pass when installing that subchart. Lets a combined component (e.g. istio-base which
/// installs both base and istiod) supply sensible defaults for each subchart's install.
/// </summary>
public string? SubchartDefaultValues { get; init; }
}
/// <summary>
@@ -66,7 +95,9 @@ public enum FormFieldType
Number,
Password,
Toggle,
Select
Select,
CnpgDatabase,
ClusterIssuer
}
/// <summary>
@@ -147,14 +178,44 @@ public static class YamlFormMerger
private static void SetValueAtPath(YamlMappingNode current, string[] segments, string value)
{
// Walk down the path, creating mappings for intermediate segments.
// Numeric segments (e.g. "0", "1") index into sequence nodes.
// On the last segment, we set the scalar value.
for (int i = 0; i < segments.Length - 1; i++)
{
YamlScalarNode key = new(segments[i]);
string seg = segments[i];
YamlScalarNode key = new(seg);
YamlNode? existing = current.Children.Keys
.OfType<YamlScalarNode>()
.FirstOrDefault(k => k.Value == segments[i]);
.FirstOrDefault(k => k.Value == seg);
// When the child is a sequence and the next segment is a numeric index,
// navigate into the sequence element and continue walking from there.
if (existing is not null
&& current.Children[existing] is YamlSequenceNode seq
&& i + 1 < segments.Length - 1
&& int.TryParse(segments[i + 1], out int arrayIndex))
{
// Ensure the sequence is long enough.
while (seq.Children.Count <= arrayIndex)
{
seq.Children.Add(new YamlMappingNode());
}
if (seq.Children[arrayIndex] is YamlMappingNode seqMapping)
{
current = seqMapping;
}
else
{
YamlMappingNode replacement = new();
seq.Children[arrayIndex] = replacement;
current = replacement;
}
i++; // skip the numeric index segment — already consumed
continue;
}
if (existing is not null && current.Children[existing] is YamlMappingNode childMapping)
{