diff --git a/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor index 2700cee..14825a9 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor @@ -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 @@ Resources + @* ── Manifests sub-tab ── *@ @@ -393,6 +404,123 @@ } + + @* ── Databases sub-tab ── *@ + @if (deploymentSection == "databases") + { +
+
+
+ + Database Bindings +
+ +
+
+ @if (!string.IsNullOrEmpty(bindingError)) + { +
@bindingError
+ } + + @* --- Add binding form --- *@ + @if (showAddBinding) + { +
+
+
Attach a PostgreSQL Database
+
+
+ + +
+
+ + +
The K8s Secret created in @selectedDeployment?.Namespace.
+
+
+
+ + +
+
+
+ } + + @* --- Binding list --- *@ + @if (databaseBindings is null) + { +
+
+
+ } + else if (databaseBindings.Count == 0) + { +
+ +

No databases attached. Attach a database to have its credentials automatically synced to this deployment's namespace.

+
+ } + else + { + + + + + + + + + + + @foreach (DatabaseBinding binding in databaseBindings) + { + + + + + + + } + +
DatabaseSecret nameLast synced
+ @if (binding.CnpgDatabase is not null) + { + + @binding.CnpgDatabase.CnpgCluster.Name / @binding.CnpgDatabase.Name + } + else if (binding.MongoDatabase is not null) + { + + @binding.MongoDatabase.MongoCluster.Name / @binding.MongoDatabase.Name + } + @binding.KubernetesSecretName + @(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never") + + +
+ } +
+
+ } } else { @@ -602,6 +730,14 @@ // Resource tree private List? resourceTree; + // Database bindings + private List? 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 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 diff --git a/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor index 3dfbb63..bf43b26 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor @@ -6,6 +6,7 @@ @inject ComponentLifecycleService LifecycleService @inject ExternalRouteService RouteService @inject ComponentScanService ScanService +@inject KeycloakService KeycloakService @* ═══════════════════════════════════════════════════════════════════ Cluster Detail — a full-page view for a single cluster. @@ -116,7 +117,7 @@ else if (section == "components")
+ @onclick="async () => { if (!alreadyAdded) await SelectCatalogEntry(entry); }">
@@ -293,6 +294,8 @@ else if (section == "components")
@foreach (ComponentFormField field in selectedCatalogEntry.FormFields) { + @if (IsFieldVisible(field, regFormFieldValues)) + {
@if (field.Type == FormFieldType.Toggle) @@ -303,6 +306,42 @@ else if (section == "components") @onchange="@(e => OnRegToggleChanged(field.Key, e))" />
} + else if (field.Type == FormFieldType.CnpgDatabase) + { + + } + else if (field.Type == FormFieldType.ClusterIssuer) + { + @if (keycloakAvailableIssuers is { Count: > 0 }) + { + + } + else + { + + } + } else if (field.Type == FormFieldType.Select && field.Options is not null) { + + @if (keycloakDatabases is not null) + { + @foreach (CnpgDatabase kcDb in keycloakDatabases) + { + + } + } + + } + else if (field.Type == FormFieldType.ClusterIssuer) + { + @if (keycloakAvailableIssuers is { Count: > 0 }) + { + + } + else + { + + } + } else if (field.Type == FormFieldType.Select && field.Options is not null) {
+
+ + +
+ + + + @if (credentialsDatabaseId == cdb.Id && databaseCredentials is not null) + { + + +
+ + + @foreach (KeyValuePair cred in databaseCredentials) + { + + + + + } + +
@cred.Key@cred.Value
+
+ K8s Secret: @($"{mc.Name}-{cdb.Name}-credentials") in namespace @mc.Namespace +
+
+ + + } } @@ -546,9 +631,9 @@ else @* ── Backups ── *@
Backups - @clusterDetail.Cluster.Backups.Count + @clusterDetail.Backups.Count
- @if (clusterDetail.Cluster.Backups.Count > 0) + @if (clusterDetail.Backups.Count > 0) {
@@ -563,9 +648,11 @@ else - @foreach (CnpgBackup bk in clusterDetail.Cluster.Backups) + @foreach (CnpgBackup bk in clusterDetail.Backups) { - +
@bk.Name @if (bk.Type == CnpgBackupType.Scheduled) @@ -602,12 +689,113 @@ else } else if (mc.StorageLinkId.HasValue) { -

No backups yet. Click to create one.

+

No backups yet. Click to create one.

} else {

Backups are not configured. Assign an S3 bucket to enable backups.

} + + @* ── Backup Restore Panel (CNPG) ── *@ + @if (selectedCnpgBackup != null && selectedCnpgBackupCluster?.Id == mc.Id) + { +
+
+
+ Restore from @selectedCnpgBackup.Name + (@(selectedCnpgBackup.CompletedAt?.ToString("yyyy-MM-dd HH:mm") ?? selectedCnpgBackup.StartedAt.ToString("yyyy-MM-dd HH:mm"))) +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (backupRestoreTarget == "current") + { +
+ + Destructive: Deletes the running cluster and recreates it from this backup. All data after the backup point is lost. +
+ } + else if (backupRestoreTarget == "new") + { +
+
+ + +
+
+ } + else if (backupRestoreTarget == "external") + { +
+ + CNPG uses physical (Barman) backups that cannot be directly restored to an external PostgreSQL server. + Use New cluster to restore within Kubernetes, then use pg_dump to export specific databases. +
+ } + +
+ + +
+ + @if (!string.IsNullOrEmpty(backupRestoreError)) + { +
@backupRestoreError
+ } +
+
+ } + + @if (configuringScheduleId == mc.Id) + { +
+ + + +
+ } } @if (!string.IsNullOrEmpty(mc.LastError)) @@ -757,29 +945,29 @@ else
MongoDB Clusters - @if (mongoClusters is not null) + @if (FilteredMongoClusters is not null) { - @mongoClusters.Count + @FilteredMongoClusters.Count }
- @if (mongoClusters is null) + @if (FilteredMongoClusters is null) {
} - else if (mongoClusters.Count == 0) + else if (FilteredMongoClusters.Count == 0) {
- No MongoDB clusters found. Create a MongoDBCommunity resource in your namespace. + No MongoDB clusters discovered on Kubernetes.
} else {
- @foreach (DatabaseClusterInfo dbCluster in mongoClusters) + @foreach (DatabaseClusterInfo dbCluster in FilteredMongoClusters) {
@@ -838,6 +1026,683 @@ else
} + @* ── Managed MongoDB Clusters ── *@ + @if (operatorStatus!.MongoDbAvailable) + { +
+
+ Managed MongoDB Clusters + @if (FilteredManagedMongoClusters is not null) + { + @FilteredManagedMongoClusters.Count + } + +
+ + @* ── Create MongoDB Cluster Form ── *@ + @if (showMongoCreate) + { +
+
+
New MongoDB Cluster
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ @if (!string.IsNullOrEmpty(mongoCreateError)) + { +
+ @mongoCreateError +
+ } +
+
+ } + + @* ── Managed MongoDB Cluster Cards ── *@ + @if (FilteredManagedMongoClusters is null) + { +
+
+
+ } + else if (FilteredManagedMongoClusters.Count == 0 && !showMongoCreate) + { +
+ + No managed MongoDB clusters yet. Click "Create Cluster" to provision a new MongoDB replica set. +
+ } + else + { + @foreach (MongoCluster mc in FilteredManagedMongoClusters) + { +
+
+
+ + + @mc.Name + Mongo @mc.MongoVersion + @(GetMongoManagedStatusBadge(mc.Status)) + + @mc.KubernetesCluster.Environment.Name + + @if (mc.Databases.Count > 0) + { + @mc.Databases.Count db(s) + } +
+
+ + @if (mc.StorageLinkId.HasValue) + { + + + } + + +
+
+ + @* ── Expanded Mongo Detail Panel ── *@ + @if (expandedMongoClusterId == mc.Id) + { +
+ @if (mongoLoadingDetail) + { +
+
+ Querying cluster status... +
+ } + else if (mongoClusterDetail is not null) + { + @* ── Cluster Overview ── *@ +
+
+
+
State
+
+ @if (mongoClusterDetail.Phase.Contains("ready", StringComparison.OrdinalIgnoreCase)) + { + @mongoClusterDetail.Phase + } + else if (mongoClusterDetail.Phase.Contains("initializing", StringComparison.OrdinalIgnoreCase)) + { + @mongoClusterDetail.Phase + } + else + { + @mongoClusterDetail.Phase + } +
+
+
+
+
+
Ready
+
@mongoClusterDetail.ReadyMembers / @mc.Members
+
+
+
+
+
Endpoint
+
@(mongoClusterDetail.Endpoint ?? "—")
+
+
+
+
+
Info
+
+ @mc.KubernetesCluster.Name + @mc.Namespace +
+
+
+
+ + @if (!string.IsNullOrEmpty(mongoClusterDetail.Message)) + { +
+ @mongoClusterDetail.Message +
+ } + + @* ── Pod Table ── *@ +
Pods
+ @if (mongoClusterDetail.Pods.Count > 0) + { +
+ + + + + + + + + + + + + @foreach (MongoPodInfo pod in mongoClusterDetail.Pods) + { + + + + + + + + + } + +
PodStatusReadyNodeAgeRestarts
@pod.Name + @if (pod.Status == "Running") + { + Running + } + else if (pod.Status == "Pending") + { + Pending + } + else + { + @pod.Status + } + + @if (pod.Ready) + { + + } + else + { + + } + @(pod.Node ?? "—")@FormatAge(pod.StartTime) + @if (pod.Restarts > 0) + { + @pod.Restarts + } + else + { + 0 + } +
+
+ } + else + { +
+ No pods found. Cluster may still be initializing. +
+ } + + @* ── Databases ── *@ +
+ Databases + @mc.Databases.Count +
+ @if (mc.Databases.Count > 0) + { +
+ + + + + + + + + + + @foreach (MongoDatabase mdb in mc.Databases) + { + + + + + + + } + +
DatabaseStatusCreated
@mdb.Name + @if (mdb.Status == MongoDatabaseStatus.Ready) + { + Ready + } + else if (mdb.Status == MongoDatabaseStatus.Creating) + { + Creating + } + else + { + @mdb.Status + } + @mdb.CreatedAt.ToString("yyyy-MM-dd HH:mm") + +
+
+ } + else + { +

No databases created yet. Use the button to add one.

+ } + + @* ── Backups ── *@ +
+ Backups + @mongoClusterDetail.Backups.Count +
+ @if (mongoClusterDetail.Backups.Count > 0) + { +
+ + + + + + + + + + + + @foreach (MongoBackup bk in mongoClusterDetail.Backups) + { + + + + + + + + } + +
NameTypeStatusStartedCompleted
@bk.Name + @if (bk.Type == MongoBackupType.Scheduled) + { + Scheduled + } + else + { + On-Demand + } + + @if (bk.Status == MongoBackupStatus.Completed) + { + Completed + } + else if (bk.Status == MongoBackupStatus.Running) + { + Running + } + else + { + Failed + } + @bk.StartedAt.ToString("yyyy-MM-dd HH:mm")@(bk.CompletedAt?.ToString("HH:mm") ?? "—")
+
+ } + else if (mc.StorageLinkId.HasValue) + { +

No backups yet. Click to create one.

+ } + else + { +

Backups are not configured. Assign an S3 bucket to enable backups.

+ } + + @* ── Backup Restore Panel (MongoDB) ── *@ + @if (selectedMongoBackup != null && selectedMongoBackupCluster?.Id == mc.Id) + { +
+
+
+ Restore from @selectedMongoBackup.Name + (@(selectedMongoBackup.CompletedAt?.ToString("yyyy-MM-dd HH:mm") ?? selectedMongoBackup.StartedAt.ToString("yyyy-MM-dd HH:mm"))) +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (backupRestoreTarget == "current") + { +
+ + Destructive: Runs mongorestore --drop against the running cluster. All collections are replaced with backup data. +
+ } + else if (backupRestoreTarget == "new") + { +
+
+ + +
+
+ } + else if (backupRestoreTarget == "external") + { +
+
+ + +
The backup will be restored (mongorestore --drop) to this external MongoDB instance.
+
+
+ } + +
+ + +
+ + @if (!string.IsNullOrEmpty(backupRestoreError)) + { +
@backupRestoreError
+ } +
+
+ } + } + + @if (!string.IsNullOrEmpty(mc.LastError)) + { +
+ @mc.LastError +
+ } +
+ } + else + { + @* ── Collapsed Summary Row ── *@ +
+
+
+ @mc.KubernetesCluster.Name +
+
+ @mc.Namespace +
+
+ @mc.Members member(s) +
+
+ @mc.StorageSize +
+ @if (mc.StorageLink is not null) + { +
+ @mc.StorageLink.BucketName +
+ } + @if (mc.Databases.Count > 0) + { +
+ @string.Join(", ", mc.Databases.Select(d => d.Name)) +
+ } +
+
+ } +
+ } + } + + @* ── Add MongoDB Database Form ── *@ + @if (mongoAddDbCluster is not null) + { +
+
+
Add Database to @mongoAddDbCluster.Name
+
+
+
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(mongoDbError)) + { +
@mongoDbError
+ } +
+
+ } + + @* ── MongoDB Restore Form ── *@ + @if (mongoRestoreSource is not null) + { +
+
+
Restore from @mongoRestoreSource.Name
+
+
+
+
+ + @{ + var completedBackups = mongoRestoreSource.Backups + .Where(b => b.Status == MongoBackupStatus.Completed) + .OrderByDescending(b => b.StartedAt) + .ToList(); + } + + @if (!completedBackups.Any()) + { +
No completed backups available.
+ } +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(mongoRestoreError)) + { +
@mongoRestoreError
+ } +
+
+ } + + @* ── MongoDB Upgrade Form ── *@ + @if (mongoUpgradeCluster is not null) + { +
+
+
Upgrade @mongoUpgradeCluster.Name (currently @mongoUpgradeCluster.MongoVersion)
+
+
+
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(mongoUpgradeError)) + { +
@mongoUpgradeError
+ } +
+
+ } +
+ } + @* ── Refresh button ── *@
+ + +
+
+ +
+
@selectedRealm.DisplayName
+ @selectedRealm.RealmName +
+
+ @if (selectedRealm.LinkedApp is not null) + { + + @selectedRealm.LinkedApp.Customer.Name / @selectedRealm.LinkedApp.Name + + } +
+ + + + @* ── Users ── *@ + @if (realmTab == "users") + { + @if (loadingUsers) + { +
+ } + else + { +
+ @(users?.Count ?? 0) users + +
+ + @if (showAddUser) + { +
+
+ @if (userError is not null) + { +
@userError
+ } +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+ } + + @if (users is not null) + { +
+ @foreach (KeycloakUserInfo user in users) + { +
+
+
+ @user.Username + @user.Email + @if (!user.Enabled) + { + Disabled + } + @if (!user.EmailVerified) + { + Unverified + } + @if (user.Attributes.Count > 0) + { +
+ @foreach (KeyValuePair> attr in user.Attributes) + { + + @attr.Key: @string.Join(", ", attr.Value) + + } +
+ } +
+ +
+
+ } +
+ } + } + } + + @* ── Identity Providers ── *@ + @if (realmTab == "idp") + { + @if (loadingIdps) + { +
+ } + else + { +
+ @(idps?.Count ?? 0) providers + +
+ + @if (showAddIdp) + { +
+
+ @if (idpError is not null) + { +
@idpError
+ } +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+ } + + @if (idps is not null) + { +
+ @foreach (KeycloakIdpInfo idp in idps) + { +
+
+ + @idp.Alias + @idp.ProviderId + @if (!idp.Enabled) + { + Disabled + } +
+ +
+ } +
+ } + } + } + + @* ── Groups ── *@ + @if (realmTab == "groups") + { + @if (loadingGroups) + { +
+ } + else + { +
+ @(groups?.Count ?? 0) groups + +
+ + @if (showAddGroup) + { +
+ + + +
+ @if (groupError is not null) + { +
@groupError
+ } + } + + @if (groups is not null) + { +
+ @foreach (KeycloakGroupInfo g in groups) + { +
+ @g.Name @g.Path + +
+ } +
+ } + } + } + + @* ── Organizations ── *@ + @if (realmTab == "orgs") + { + @if (loadingOrgs) + { +
+ } + else + { +
+ @(orgs?.Count ?? 0) organizations (Keycloak 26+) + +
+ + @if (showAddOrg) + { +
+
+ @if (orgError is not null) + { +
@orgError
+ } +
+
+ +
+
+ +
+
+
+ + +
+
+
+ } + + @if (orgs is not null) + { +
+ @foreach (KeycloakOrganizationInfo org in orgs) + { +
+ @org.Name + @if (!string.IsNullOrWhiteSpace(org.Description)) + { + @org.Description + } + @if (!org.Enabled) + { + Disabled + } +
+ } +
+ } + } + } + + @* ── Themes ── *@ + @if (realmTab == "themes") + { + @if (realmDetails is null) + { +
+ } + else + { + @if (realmThemeError is not null) + { +
@realmThemeError
+ } +
+
+ + +
+
+ + +
+
+ +
+
+ } + } + + @* ── Backup & Restore ── *@ + @if (realmTab == "backup") + { + @if (loadingBackups) + { +
+ } + else + { + @if (backupError is not null) + { +
@backupError
+ } + +
+
+
Create Backup
+
+
+ + +
+
+ +
+
+
+
+ + @if (backups is not null && backups.Count > 0) + { +
Backups
+
+ @foreach (KeycloakBackup b in backups) + { +
+
+
+ @b.RealmName + @b.CreatedAt.ToString("yyyy-MM-dd HH:mm") + @if (b.Status == KeycloakBackupStatus.Ready) + { + Ready + @FormatSize(b.SizeBytes) + } + else if (b.Status == KeycloakBackupStatus.Failed) + { + Failed + @b.LastError + } + else + { + Creating + } +
+ @if (b.Status == KeycloakBackupStatus.Ready) + { +
+ + +
+ } +
+
+ } +
+ } + } + } + + @* ── Customer Portal ── *@ + @if (realmTab == "portal") + { + @if (portalError is not null) + { +
@portalError
+ } + @if (selectedRealm.LinkedApp is not null) + { +
+ + Linked to @selectedRealm.LinkedApp.Customer.Name / @selectedRealm.LinkedApp.Name + +
+ } + else + { +

Link this realm to a customer app so the customer can manage users, groups, and IdPs through their portal.

+
+
+ + +
+
+ +
+
+ } + } +} +else if (selectedKeycloak is not null) +{ + @* ── Level 2: Realm list for a specific Keycloak ── *@ + + +
+ +
+
@selectedKeycloak.Component.Name + @selectedKeycloak.Component.Cluster.Name +
+ @if (selectedKeycloak.Config?.AdminUrl is not null) + { + @selectedKeycloak.Config.AdminUrl + } +
+ +
+ + @if (showEditConfig && selectedKeycloak.Config is not null) + { +
+
Keycloak Settings
+
+ @if (configError is not null) + { +
@configError
+ } +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ } + + @if (!selectedKeycloak.IsConfigured) + { + @* Setup form for unconfigured keycloak *@ +
+
+ Not Configured +
+
+ @if (selectedKeycloak.Component.Status == ComponentStatus.Failed) + { +
+ + The install failed — likely because the database credentials weren't available before + the pod started. Configure the database here, then retry the install from the + Cluster tab. +
+ } +

+ Enter the admin credentials for this Keycloak instance. Optionally link a managed + CNPG database — if the instance already runs against its own Postgres, leave the + database unselected. Credentials are synced to a Kubernetes Secret in the + @(selectedKeycloak.Component.Namespace ?? "keycloak") namespace. +

+ @if (setupError is not null) + { +
@setupError
+ } +
+
+ + + @if (availableDatabases is not null && availableDatabases.Count == 0) + { +
+ No ready CNPG databases found on this cluster. Create one in the Databases tab first. +
+ } +
+
+ + + @if (!string.IsNullOrWhiteSpace(selectedKeycloak.SuggestedAdminUrl)) + { + + } +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ } + else + { + @* Instance-level tabs: Realms | Themes *@ + + + @if (instanceTab == "themes") + { + @* ── Themes tab ── *@ +
+ @foreach (string type in new[] { "login", "account", "admin", "email" }) + { + + } +
+ + @if (loadingThemes) + { +
+ Loading themes... + } + else + { + @if (availableThemes is not null && availableThemes.TryGetValue(themeType, out List? themeNames) && themeNames.Count > 0) + { +
+
Available themes
+
+ @foreach (string name in themeNames) + { + @name + } +
+
+ } + +
+ +
+ Stored in EntKube. Apply to realms individually via the realm's Themes tab. +
+ +
+
+ + @if (themeSaved) + { + Saved + } + @if (themeError is not null) + { + @themeError + } +
+ } + } + else + { + @* Realms list *@ + @if (realmSyncError is not null) + { +
+ +
+ Could not reach Keycloak — showing cached realms.
+ @realmSyncError +
+
+ } +
+ @(realms?.Count ?? 0) realms + +
+ + @if (showCreateRealm) + { +
+
+ @if (realmError is not null) + { +
@realmError
+ } +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ } + + @if (realms is not null) + { + @if (realms.Count == 0) + { +
+ + No realms yet. Create one above. +
+ } + else + { +
+ @foreach (KeycloakRealm realm in realms) + { +
+
+ +
+ @realm.DisplayName + @realm.RealmName + @if (!realm.Enabled) + { + Disabled + } + @if (realm.LinkedApp is not null) + { + + @realm.LinkedApp.Customer.Name / @realm.LinkedApp.Name + + } +
+
+
+ + +
+
+ } +
+ } + } + } @* end realms tab else *@ + } +} +else +{ + @* ── Level 1: Keycloak instance list ── *@ + @if (detectedKeycloaks is null || detectedKeycloaks.Count == 0) + { +
+ +

No Keycloak instances found on this tenant's clusters.

+ Install the Keycloak component from the Components tab on one of your clusters. +
+ } + else + { +

+ Detected @detectedKeycloaks.Count Keycloak @(detectedKeycloaks.Count == 1 ? "instance" : "instances"). + Select one to manage realms, users, identity providers, and backups. +

+ +
+ @foreach (DetectedKeycloak kc in detectedKeycloaks) + { +
+
+
+
+
+
@kc.Component.Name
+ @kc.Component.Cluster.Name +
+
+ @if (kc.Component.Status == ComponentStatus.Failed) + { + + Install failed + + } + @if (kc.IsConfigured) + { + + Configured + + } + else + { + + Setup needed + + } +
+
+ @if (kc.Config?.AdminUrl is not null) + { +
@kc.Config.AdminUrl
+ } + @if (kc.Config?.CnpgDatabase is not null) + { +
+ + @kc.Config.CnpgDatabase.CnpgCluster.Name / @kc.Config.CnpgDatabase.Name +
+ } +
+ +
+
+ } +
+ } +} + +@code { + [Parameter] public Guid TenantId { get; set; } + + // ── Load state ──────────────────────────────────────────────────────────── + private bool loading = true; + private List? detectedKeycloaks; + private List? realms; + private List? availableDatabases; + private List? storageLinks; + private List? apps; + + // ── Navigation ──────────────────────────────────────────────────────────── + private DetectedKeycloak? selectedKeycloak; + private KeycloakRealm? selectedRealm; + private string realmTab = "users"; + private string instanceTab = "realms"; + + // ── Themes tab ─────────────────────────────────────────────────────────── + private string themeType = "login"; + private Dictionary>? availableThemes; + private bool loadingThemes; + private string? themeCss; + private bool savingTheme; + private bool themeSaved; + private string? themeError; + + // ── Per-tab lazy data ───────────────────────────────────────────────────── + private List? users; + private List? idps; + private List? groups; + private List? orgs; + private List? backups; + private KeycloakRealmDetails? realmDetails; + + private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs, loadingBackups; + + // ── Setup form ──────────────────────────────────────────────────────────── + private bool configuringKeycloak; + private string? setupError; + private Guid setupDatabaseId; + private string setupAdminUrl = ""; + private string setupUsername = "admin"; + private string setupPassword = ""; + + // ── Edit config form ────────────────────────────────────────────────────── + private bool showEditConfig; + private bool savingConfig; + private string? configError; + private string editAdminUrl = ""; + private string editAdminUsername = "admin"; + private string editAdminPassword = ""; + + // ── Realm form ──────────────────────────────────────────────────────────── + private bool showCreateRealm; + private bool creatingRealm; + private string? realmError; + private string? realmSyncError; + private string newRealmName = ""; + private string newRealmDisplayName = ""; + private string newRealmTheme = ""; + private Guid? deletingRealmId; + + // ── User form ───────────────────────────────────────────────────────────── + private bool showAddUser; + private bool savingUser; + private string? userError; + private string newUserUsername = ""; + private string newUserEmail = ""; + private string newUserFirstName = ""; + private string newUserLastName = ""; + private string newUserPassword = ""; + + // ── IdP form ────────────────────────────────────────────────────────────── + private bool showAddIdp; + private bool savingIdp; + private string? idpError; + private string newIdpAlias = ""; + private string newIdpProviderId = "oidc"; + private string newIdpClientId = ""; + private string newIdpClientSecret = ""; + + // ── Group form ──────────────────────────────────────────────────────────── + private bool showAddGroup; + private bool savingGroup; + private string? groupError; + private string newGroupName = ""; + + // ── Org form ───────────────────────────────────────────────────────────── + private bool showAddOrg; + private bool savingOrg; + private string? orgError; + private string newOrgName = ""; + private string newOrgDescription = ""; + + // ── Realm theme form ────────────────────────────────────────────────────── + private bool savingThemes; + private string? realmThemeError; + private string editLoginTheme = ""; + private string editAccountTheme = ""; + + // ── Backup form ─────────────────────────────────────────────────────────── + private bool creatingBackup; + private string? backupError; + private Guid backupStorageLinkId; + + // ── Portal form ─────────────────────────────────────────────────────────── + private string? portalError; + private Guid linkAppId; + + protected override async Task OnInitializedAsync() + { + detectedKeycloaks = await KeycloakService.GetDetectedKeycloaksAsync(TenantId); + loading = false; + } + + private async Task SelectKeycloak(DetectedKeycloak kc) + { + selectedKeycloak = kc; + realms = null; + availableDatabases = null; + setupError = null; + setupDatabaseId = Guid.Empty; + setupAdminUrl = kc.SuggestedAdminUrl ?? ""; + setupPassword = ""; + instanceTab = "realms"; + availableThemes = null; + themeCss = null; + themeSaved = false; + themeError = null; + realmSyncError = null; + + if (kc.IsConfigured) + { + try + { + realms = await KeycloakService.SyncRealmsFromKeycloakAsync(TenantId, kc.Config!.Id); + } + catch (Exception ex) + { + realmSyncError = ex.Message; + realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, kc.Config!.Id); + } + storageLinks ??= await StorageService.GetStorageLinksAsync(TenantId); + apps ??= await KeycloakService.GetAppsAsync(TenantId); + } + else + { + availableDatabases = await KeycloakService.GetDatabasesForComponentAsync( + TenantId, kc.Component.Id); + } + + if (kc.Config is not null) + { + editAdminUrl = kc.Config.AdminUrl ?? ""; + editAdminUsername = kc.Config.AdminUsername; + } + } + + private async Task SwitchInstanceTab(string tab) + { + instanceTab = tab; + themeSaved = false; + themeError = null; + + if (tab == "themes" && selectedKeycloak?.Config is not null) + { + await LoadThemesTab(); + } + } + + private async Task SwitchThemeType(string type) + { + themeType = type; + themeSaved = false; + themeError = null; + await LoadThemeCss(); + } + + private async Task LoadThemesTab() + { + if (selectedKeycloak?.Config is null) return; + + loadingThemes = true; + StateHasChanged(); + + try + { + availableThemes = await KeycloakService.GetAvailableThemesAsync(TenantId, selectedKeycloak.Config.Id); + await LoadThemeCss(); + } + catch + { + availableThemes = []; + } + finally + { + loadingThemes = false; + } + } + + private async Task LoadThemeCss() + { + if (selectedKeycloak is null) return; + themeCss = await KeycloakService.GetThemeCssAsync(TenantId, selectedKeycloak.Component.Id, themeType); + } + + private async Task SaveThemeCss() + { + if (selectedKeycloak is null) return; + + savingTheme = true; + themeSaved = false; + themeError = null; + + try + { + await KeycloakService.SaveThemeCssAsync( + TenantId, selectedKeycloak.Component.Id, themeType, themeCss ?? ""); + themeSaved = true; + } + catch (Exception ex) + { + themeError = ex.Message; + } + finally + { + savingTheme = false; + } + } + + private void BackToList() + { + selectedKeycloak = null; + selectedRealm = null; + realms = null; + } + + private async Task OpenRealm(KeycloakRealm realm) + { + selectedRealm = realm; + realmTab = "users"; + users = null; + idps = null; + groups = null; + orgs = null; + backups = null; + realmDetails = null; + await SwitchRealmTab("users"); + } + + private void BackToRealms() + { + selectedRealm = null; + users = null; + idps = null; + groups = null; + orgs = null; + backups = null; + realmDetails = null; + } + + private async Task SwitchRealmTab(string tab) + { + realmTab = tab; + + if (selectedRealm is null) return; + + switch (tab) + { + case "users" when users is null: + loadingUsers = true; + StateHasChanged(); + try { users = await KeycloakService.GetUsersAsync(TenantId, selectedRealm.Id); } + catch (Exception ex) { userError = ex.Message; } + finally { loadingUsers = false; } + break; + + case "idp" when idps is null: + loadingIdps = true; + StateHasChanged(); + try { idps = await KeycloakService.GetIdpsAsync(TenantId, selectedRealm.Id); } + catch (Exception ex) { idpError = ex.Message; } + finally { loadingIdps = false; } + break; + + case "groups" when groups is null: + loadingGroups = true; + StateHasChanged(); + try { groups = await KeycloakService.GetGroupsAsync(TenantId, selectedRealm.Id); } + catch (Exception ex) { groupError = ex.Message; } + finally { loadingGroups = false; } + break; + + case "orgs" when orgs is null: + loadingOrgs = true; + StateHasChanged(); + try { orgs = await KeycloakService.GetOrganizationsAsync(TenantId, selectedRealm.Id); } + catch (Exception ex) { orgError = ex.Message; } + finally { loadingOrgs = false; } + break; + + case "themes" when realmDetails is null: + StateHasChanged(); + try + { + realmDetails = await KeycloakService.GetRealmDetailsAsync(TenantId, selectedRealm.Id); + editLoginTheme = realmDetails.LoginTheme ?? ""; + editAccountTheme = realmDetails.AccountTheme ?? ""; + } + catch (Exception ex) { realmThemeError = ex.Message; } + break; + + case "backup" when backups is null: + loadingBackups = true; + storageLinks ??= await StorageService.GetStorageLinksAsync(TenantId); + StateHasChanged(); + try { backups = await KeycloakService.GetBackupsAsync(TenantId, selectedRealm.Id); } + catch (Exception ex) { backupError = ex.Message; } + finally { loadingBackups = false; } + break; + + case "portal": + apps ??= await KeycloakService.GetAppsAsync(TenantId); + break; + } + } + + // ── Setup ───────────────────────────────────────────────────────────────── + + private void UseSuggestedUrl() + { + setupAdminUrl = selectedKeycloak?.SuggestedAdminUrl ?? ""; + } + + private async Task ConfigureKeycloak() + { + if (selectedKeycloak is null) return; + + configuringKeycloak = true; + setupError = null; + + try + { + KeycloakComponentConfig config = await KeycloakService.ConfigureAsync( + TenantId, + selectedKeycloak.Component.Id, + setupDatabaseId == Guid.Empty ? null : setupDatabaseId, + setupUsername, + setupPassword, + setupAdminUrl); + + // Sync the credentials to the K8s secret immediately so the pod can start. + await LifecycleService.SyncComponentSecretsAsync(selectedKeycloak.Component.Id); + + // Import any realms that already exist in this Keycloak instance. + await KeycloakService.SyncRealmsFromKeycloakAsync(TenantId, config.Id); + + // Refresh detection list and re-select the now-configured keycloak. + detectedKeycloaks = await KeycloakService.GetDetectedKeycloaksAsync(TenantId); + DetectedKeycloak? updated = detectedKeycloaks.FirstOrDefault( + k => k.Component.Id == selectedKeycloak.Component.Id); + + if (updated is not null) + { + await SelectKeycloak(updated); + } + } + catch (Exception ex) + { + setupError = ex.Message; + } + finally + { + configuringKeycloak = false; + } + } + + private async Task SaveConfig() + { + if (selectedKeycloak?.Config is null) return; + + savingConfig = true; + configError = null; + + try + { + await KeycloakService.UpdateConfigAsync( + TenantId, selectedKeycloak.Config.Id, + editAdminUrl, editAdminUsername, + string.IsNullOrWhiteSpace(editAdminPassword) ? null : editAdminPassword); + + showEditConfig = false; + detectedKeycloaks = await KeycloakService.GetDetectedKeycloaksAsync(TenantId); + DetectedKeycloak? updated = detectedKeycloaks.FirstOrDefault( + k => k.Component.Id == selectedKeycloak.Component.Id); + if (updated is not null) selectedKeycloak = updated; + } + catch (Exception ex) + { + configError = ex.Message; + } + finally + { + savingConfig = false; + } + } + + // ── Realm CRUD ──────────────────────────────────────────────────────────── + + private async Task CreateRealm() + { + if (selectedKeycloak?.Config is null) return; + + creatingRealm = true; + realmError = null; + + try + { + await KeycloakService.CreateRealmAsync( + TenantId, selectedKeycloak.Config.Id, + newRealmName, newRealmDisplayName, + string.IsNullOrWhiteSpace(newRealmTheme) ? null : newRealmTheme); + + realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id); + showCreateRealm = false; + newRealmName = ""; + newRealmDisplayName = ""; + newRealmTheme = ""; + } + catch (Exception ex) + { + realmError = ex.Message; + } + finally + { + creatingRealm = false; + } + } + + private async Task DeleteRealm(Guid realmId) + { + if (selectedKeycloak?.Config is null) return; + + deletingRealmId = realmId; + + try + { + await KeycloakService.DeleteRealmAsync(TenantId, realmId); + realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id); + } + catch (Exception ex) + { + realmError = ex.Message; + } + finally + { + deletingRealmId = null; + } + } + + // ── User CRUD ───────────────────────────────────────────────────────────── + + private async Task AddUser() + { + if (selectedRealm is null) return; + + savingUser = true; + userError = null; + + try + { + await KeycloakService.CreateUserAsync( + TenantId, selectedRealm.Id, + newUserUsername, newUserEmail, newUserFirstName, newUserLastName, + string.IsNullOrWhiteSpace(newUserPassword) ? null : newUserPassword); + + users = await KeycloakService.GetUsersAsync(TenantId, selectedRealm.Id); + showAddUser = false; + newUserUsername = ""; + newUserEmail = ""; + newUserFirstName = ""; + newUserLastName = ""; + newUserPassword = ""; + } + catch (Exception ex) + { + userError = ex.Message; + } + finally + { + savingUser = false; + } + } + + private async Task DeleteUser(string userId) + { + if (selectedRealm is null) return; + + try + { + await KeycloakService.DeleteUserAsync(TenantId, selectedRealm.Id, userId); + users = await KeycloakService.GetUsersAsync(TenantId, selectedRealm.Id); + } + catch (Exception ex) + { + userError = ex.Message; + } + } + + // ── IdP CRUD ────────────────────────────────────────────────────────────── + + private async Task AddIdp() + { + if (selectedRealm is null) return; + + savingIdp = true; + idpError = null; + + try + { + await KeycloakService.CreateIdpAsync( + TenantId, selectedRealm.Id, + newIdpAlias, newIdpProviderId, newIdpClientId, newIdpClientSecret); + + idps = await KeycloakService.GetIdpsAsync(TenantId, selectedRealm.Id); + showAddIdp = false; + newIdpAlias = ""; + newIdpClientId = ""; + newIdpClientSecret = ""; + } + catch (Exception ex) + { + idpError = ex.Message; + } + finally + { + savingIdp = false; + } + } + + private async Task DeleteIdp(string alias) + { + if (selectedRealm is null) return; + + try + { + await KeycloakService.DeleteIdpAsync(TenantId, selectedRealm.Id, alias); + idps = await KeycloakService.GetIdpsAsync(TenantId, selectedRealm.Id); + } + catch (Exception ex) + { + idpError = ex.Message; + } + } + + // ── Group CRUD ──────────────────────────────────────────────────────────── + + private async Task AddGroup() + { + if (selectedRealm is null) return; + + savingGroup = true; + groupError = null; + + try + { + await KeycloakService.CreateGroupAsync(TenantId, selectedRealm.Id, newGroupName); + groups = await KeycloakService.GetGroupsAsync(TenantId, selectedRealm.Id); + showAddGroup = false; + newGroupName = ""; + } + catch (Exception ex) + { + groupError = ex.Message; + } + finally + { + savingGroup = false; + } + } + + private async Task DeleteGroup(string groupId) + { + if (selectedRealm is null) return; + + try + { + await KeycloakService.DeleteGroupAsync(TenantId, selectedRealm.Id, groupId); + groups = await KeycloakService.GetGroupsAsync(TenantId, selectedRealm.Id); + } + catch (Exception ex) + { + groupError = ex.Message; + } + } + + // ── Org CRUD ────────────────────────────────────────────────────────────── + + private async Task AddOrg() + { + if (selectedRealm is null) return; + + savingOrg = true; + orgError = null; + + try + { + await KeycloakService.CreateOrganizationAsync( + TenantId, selectedRealm.Id, newOrgName, + string.IsNullOrWhiteSpace(newOrgDescription) ? null : newOrgDescription); + + orgs = await KeycloakService.GetOrganizationsAsync(TenantId, selectedRealm.Id); + showAddOrg = false; + newOrgName = ""; + newOrgDescription = ""; + } + catch (Exception ex) + { + orgError = ex.Message; + } + finally + { + savingOrg = false; + } + } + + // ── Themes ──────────────────────────────────────────────────────────────── + + private async Task SaveThemes() + { + if (selectedRealm is null) return; + + savingThemes = true; + realmThemeError = null; + + try + { + await KeycloakService.UpdateRealmThemesAsync( + TenantId, selectedRealm.Id, + string.IsNullOrWhiteSpace(editLoginTheme) ? null : editLoginTheme, + string.IsNullOrWhiteSpace(editAccountTheme) ? null : editAccountTheme); + } + catch (Exception ex) + { + realmThemeError = ex.Message; + } + finally + { + savingThemes = false; + } + } + + // ── Backup ──────────────────────────────────────────────────────────────── + + private async Task CreateBackup() + { + if (selectedRealm is null || backupStorageLinkId == Guid.Empty) return; + + creatingBackup = true; + backupError = null; + + try + { + await KeycloakService.CreateBackupAsync(TenantId, selectedRealm.Id, backupStorageLinkId); + backups = await KeycloakService.GetBackupsAsync(TenantId, selectedRealm.Id); + } + catch (Exception ex) + { + backupError = ex.Message; + } + finally + { + creatingBackup = false; + } + } + + private async Task RestoreBackup(Guid backupId) + { + backupError = null; + + try + { + await KeycloakService.RestoreBackupAsync(TenantId, backupId, null); + } + catch (Exception ex) + { + backupError = ex.Message; + } + } + + private async Task DeleteBackup(Guid backupId) + { + backupError = null; + + try + { + await KeycloakService.DeleteBackupAsync(TenantId, backupId); + if (selectedRealm is not null) + { + backups = await KeycloakService.GetBackupsAsync(TenantId, selectedRealm.Id); + } + } + catch (Exception ex) + { + backupError = ex.Message; + } + } + + // ── Portal ──────────────────────────────────────────────────────────────── + + private async Task LinkApp() + { + if (selectedRealm is null || linkAppId == Guid.Empty) return; + + portalError = null; + + try + { + await KeycloakService.LinkRealmToAppAsync(TenantId, selectedRealm.Id, linkAppId); + + if (selectedKeycloak?.Config is not null) + { + realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id); + selectedRealm = realms.FirstOrDefault(r => r.Id == selectedRealm.Id); + } + } + catch (Exception ex) + { + portalError = ex.Message; + } + } + + private async Task UnlinkApp() + { + if (selectedRealm is null) return; + + portalError = null; + + try + { + await KeycloakService.UnlinkRealmFromAppAsync(TenantId, selectedRealm.Id); + + if (selectedKeycloak?.Config is not null) + { + realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id); + selectedRealm = realms.FirstOrDefault(r => r.Id == selectedRealm.Id); + } + } + catch (Exception ex) + { + portalError = ex.Message; + } + } + + // ── Utilities ───────────────────────────────────────────────────────────── + + private static string FormatSize(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes / (1024.0 * 1024):F1} MB"; + } +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor b/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor index e60b9b8..d42cec8 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor @@ -172,6 +172,19 @@ else title="Manage bucket (CORS, Policy)"> + +
+
+ @if (!string.IsNullOrEmpty(browseError)) + { +
+ @browseError +
+ } + else if (browsedBuckets is null) + { +
+
+ Loading buckets... +
+ } + else if (browsedBuckets.Count == 0) + { +

No buckets found under this project.

+ } + else + { +
+ + + + + + + + + + @foreach (S3BucketInfo bucket in browsedBuckets) + { + bool registered = storageLinks.Any(l => + l.Provider == StorageProvider.CleuraS3 && + l.BucketName == bucket.Name); + + + + + + } + +
Bucket NameCreatedRegistered in EntKube
@bucket.Name + @(bucket.CreatedAt == default ? "—" : bucket.CreatedAt.ToString("yyyy-MM-dd")) + + @if (registered) + { + Yes + } + else + { + No + } +
+
+

+ + @browsedBuckets.Count bucket(s) accessible. Buckets not registered in EntKube were created outside the platform. +

+ } +
+
+ } + @* ── 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? 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) diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor index 7d7222c..a5cd41e 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor @@ -63,6 +63,11 @@ else Storage +
@@ -86,6 +91,9 @@ else case "storage": break; + case "identity": + + break; }
} diff --git a/src/EntKube.Web/Data/AppDeployment.cs b/src/EntKube.Web/Data/AppDeployment.cs index 472dba7..8077106 100644 --- a/src/EntKube.Web/Data/AppDeployment.cs +++ b/src/EntKube.Web/Data/AppDeployment.cs @@ -86,4 +86,5 @@ public class AppDeployment public ICollection Manifests { get; set; } = []; public ICollection Resources { get; set; } = []; public ICollection StorageBindings { get; set; } = []; + public ICollection DatabaseBindings { get; set; } = []; } diff --git a/src/EntKube.Web/Data/ApplicationDbContext.cs b/src/EntKube.Web/Data/ApplicationDbContext.cs index 8f18489..f1d4ade 100644 --- a/src/EntKube.Web/Data/ApplicationDbContext.cs +++ b/src/EntKube.Web/Data/ApplicationDbContext.cs @@ -36,6 +36,13 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext< public DbSet CnpgClusters => Set(); public DbSet CnpgDatabases => Set(); public DbSet CnpgBackups => Set(); + public DbSet MongoClusters => Set(); + public DbSet MongoDatabases => Set(); + public DbSet DatabaseBindings => Set(); + public DbSet MongoBackups => Set(); + public DbSet KeycloakComponentConfigs => Set(); + public DbSet KeycloakRealms => Set(); + public DbSet KeycloakBackups => Set(); 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(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(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(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(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(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(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(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); + }); } } diff --git a/src/EntKube.Web/Data/CnpgBackup.cs b/src/EntKube.Web/Data/CnpgBackup.cs index c5f777d..c398e79 100644 --- a/src/EntKube.Web/Data/CnpgBackup.cs +++ b/src/EntKube.Web/Data/CnpgBackup.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations.Schema; + namespace EntKube.Web.Data; /// @@ -69,6 +71,14 @@ public class CnpgBackup /// public string? LastError { get; set; } + /// + /// 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. + /// + [NotMapped] + public string? BarmanId { get; set; } + // Navigation public CnpgCluster CnpgCluster { get; set; } = null!; } diff --git a/src/EntKube.Web/Data/CnpgCluster.cs b/src/EntKube.Web/Data/CnpgCluster.cs index 5abdaae..675bbcc 100644 --- a/src/EntKube.Web/Data/CnpgCluster.cs +++ b/src/EntKube.Web/Data/CnpgCluster.cs @@ -77,6 +77,12 @@ public class CnpgCluster /// public string? BackupSchedule { get; set; } + /// + /// 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. + /// + public int RetentionDays { get; set; } = 30; + /// /// Current lifecycle status of the cluster. /// diff --git a/src/EntKube.Web/Data/CnpgClusterDetail.cs b/src/EntKube.Web/Data/CnpgClusterDetail.cs index 98106f6..cceac73 100644 --- a/src/EntKube.Web/Data/CnpgClusterDetail.cs +++ b/src/EntKube.Web/Data/CnpgClusterDetail.cs @@ -41,6 +41,12 @@ public class CnpgClusterDetail /// The current write LAG in bytes across replicas (0 = fully caught up). /// public long ReplicationLagBytes { get; set; } + + /// + /// 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. + /// + public List Backups { get; set; } = []; } /// diff --git a/src/EntKube.Web/Data/CnpgDatabase.cs b/src/EntKube.Web/Data/CnpgDatabase.cs index 8c9c462..5fe1acb 100644 --- a/src/EntKube.Web/Data/CnpgDatabase.cs +++ b/src/EntKube.Web/Data/CnpgDatabase.cs @@ -51,4 +51,5 @@ public class CnpgDatabase // Navigation public CnpgCluster CnpgCluster { get; set; } = null!; + public ICollection DatabaseBindings { get; set; } = []; } diff --git a/src/EntKube.Web/Data/DatabaseBinding.cs b/src/EntKube.Web/Data/DatabaseBinding.cs new file mode 100644 index 0000000..326ccbd --- /dev/null +++ b/src/EntKube.Web/Data/DatabaseBinding.cs @@ -0,0 +1,60 @@ +namespace EntKube.Web.Data; + +/// +/// 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. +/// +public class DatabaseBinding +{ + public Guid Id { get; set; } + + /// + /// The CNPG database whose credentials should be synced to the app. + /// Mutually exclusive with MongoDatabaseId. + /// + public Guid? CnpgDatabaseId { get; set; } + + /// + /// The MongoDB database whose credentials should be synced to the app. + /// Mutually exclusive with CnpgDatabaseId. + /// + public Guid? MongoDatabaseId { get; set; } + + /// + /// The deployment that consumes the database credentials. + /// The secret is written into this deployment's namespace on its cluster. + /// + public Guid AppDeploymentId { get; set; } + + /// + /// The Kubernetes Secret name to create in the app's namespace. + /// Choose something meaningful to the app (e.g. "billing-db", "app-postgres"). + /// + public required string KubernetesSecretName { get; set; } + + /// + /// When true, credential syncs (including password rotations) automatically + /// propagate to this app's namespace. Disable to pause propagation without + /// removing the binding record. + /// + public bool SyncEnabled { get; set; } = true; + + /// + /// Last time credentials were successfully written to the app's namespace. + /// Null means the binding exists but has not been synced yet. + /// + 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!; +} diff --git a/src/EntKube.Web/Data/KeycloakBackup.cs b/src/EntKube.Web/Data/KeycloakBackup.cs new file mode 100644 index 0000000..37a4a3e --- /dev/null +++ b/src/EntKube.Web/Data/KeycloakBackup.cs @@ -0,0 +1,49 @@ +namespace EntKube.Web.Data; + +public enum KeycloakBackupStatus +{ + Creating, + Ready, + Failed +} + +/// +/// 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. +/// +public class KeycloakBackup +{ + public Guid Id { get; set; } + + public Guid KeycloakRealmId { get; set; } + + public Guid TenantId { get; set; } + + public Guid StorageLinkId { get; set; } + + /// + /// S3 object key (e.g. "keycloak-backups/my-realm/2026-05-19T14-32-00.json"). + /// + public required string ObjectKey { get; set; } + + /// + /// Snapshot of the realm name at backup time, for display after the realm + /// may have been renamed or deleted. + /// + 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!; +} diff --git a/src/EntKube.Web/Data/KeycloakComponentConfig.cs b/src/EntKube.Web/Data/KeycloakComponentConfig.cs new file mode 100644 index 0000000..b059515 --- /dev/null +++ b/src/EntKube.Web/Data/KeycloakComponentConfig.cs @@ -0,0 +1,52 @@ +namespace EntKube.Web.Data; + +/// +/// 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. +/// +public class KeycloakComponentConfig +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + /// + /// The installed Keycloak ClusterComponent this config belongs to. + /// One config per Keycloak component instance. + /// + public Guid ClusterComponentId { get; set; } + + /// + /// The managed CNPG database backing this Keycloak instance. + /// Null until the operator selects a database. + /// + public Guid? CnpgDatabaseId { get; set; } + + /// + /// Keycloak admin username (default "admin"). + /// + public string AdminUsername { get; set; } = "admin"; + + /// + /// 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. + /// + 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 Realms { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/KeycloakRealm.cs b/src/EntKube.Web/Data/KeycloakRealm.cs new file mode 100644 index 0000000..1d9a942 --- /dev/null +++ b/src/EntKube.Web/Data/KeycloakRealm.cs @@ -0,0 +1,51 @@ +namespace EntKube.Web.Data; + +/// +/// 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. +/// +public class KeycloakRealm +{ + public Guid Id { get; set; } + + public Guid KeycloakComponentConfigId { get; set; } + + public Guid TenantId { get; set; } + + /// + /// The Keycloak realm ID (e.g. "acme-prod"). Used in all API calls. + /// + public required string RealmName { get; set; } + + /// + /// Human-readable display name shown inside the Keycloak login UI. + /// + public required string DisplayName { get; set; } + + public bool Enabled { get; set; } = true; + + /// + /// Login page theme assigned to this realm (e.g. "keycloak", "custom-brand"). + /// Null means the Keycloak default. + /// + public string? LoginTheme { get; set; } + + /// + /// Account console theme for self-service password management. + /// + public string? AccountTheme { get; set; } + + /// + /// When set, this realm is linked to a customer app and the customer can + /// manage their realm (users, groups, IdPs) through the portal. + /// + 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 Backups { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260518072619_AddCnpgRetentionDays.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260518072619_AddCnpgRetentionDays.Designer.cs new file mode 100644 index 0000000..3df3d0d --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260518072619_AddCnpgRetentionDays.Designer.cs @@ -0,0 +1,1723 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260518072619_AddCnpgRetentionDays")] + partial class AddCnpgRetentionDays + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Instances") + .HasColumnType("integer"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RetentionDays") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServicePort") + .HasColumnType("integer"); + + b.Property("TlsCertificate") + .HasColumnType("text"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectDomainName") + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("text"); + + b.Property("ProjectName") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserDomainName") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BucketName") + .HasColumnType("text"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Navigation("Backups"); + + b.Navigation("Databases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260518072619_AddCnpgRetentionDays.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260518072619_AddCnpgRetentionDays.cs new file mode 100644 index 0000000..0ef6b65 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260518072619_AddCnpgRetentionDays.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddCnpgRetentionDays : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RetentionDays", + table: "CnpgClusters", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RetentionDays", + table: "CnpgClusters"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260518082344_AddMongoEntities.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260518082344_AddMongoEntities.Designer.cs new file mode 100644 index 0000000..8e953f0 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260518082344_AddMongoEntities.Designer.cs @@ -0,0 +1,1925 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260518082344_AddMongoEntities")] + partial class AddMongoEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Instances") + .HasColumnType("integer"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RetentionDays") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServicePort") + .HasColumnType("integer"); + + b.Property("TlsCertificate") + .HasColumnType("text"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Members") + .HasColumnType("integer"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("RetentionDays") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectDomainName") + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("text"); + + b.Property("ProjectName") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserDomainName") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BucketName") + .HasColumnType("text"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("MongoDatabaseId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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.MongoDatabase", "MongoDatabase") + .WithMany() + .HasForeignKey("MongoDatabaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Navigation("Backups"); + + b.Navigation("Databases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + 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.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260518082344_AddMongoEntities.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260518082344_AddMongoEntities.cs new file mode 100644 index 0000000..c1981ad --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260518082344_AddMongoEntities.cs @@ -0,0 +1,175 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddMongoEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MongoDatabaseId", + table: "VaultSecrets", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "MongoClusters", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + KubernetesClusterId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), + Namespace = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), + MongoVersion = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Members = table.Column(type: "integer", nullable: false), + StorageSize = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + StorageLinkId = table.Column(type: "uuid", nullable: true), + BackupSchedule = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + RetentionDays = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + LastError = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + MongoClusterId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + Type = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + SizeBytes = table.Column(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(type: "uuid", nullable: false), + MongoClusterId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), + Owner = table.Column(type: "text", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260518095816_AddDatabaseBindings.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260518095816_AddDatabaseBindings.Designer.cs new file mode 100644 index 0000000..37328c9 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260518095816_AddDatabaseBindings.Designer.cs @@ -0,0 +1,2002 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260518095816_AddDatabaseBindings")] + partial class AddDatabaseBindings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Instances") + .HasColumnType("integer"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RetentionDays") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoDatabaseId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServicePort") + .HasColumnType("integer"); + + b.Property("TlsCertificate") + .HasColumnType("text"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Members") + .HasColumnType("integer"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("RetentionDays") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectDomainName") + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("text"); + + b.Property("ProjectName") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserDomainName") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BucketName") + .HasColumnType("text"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("MongoDatabaseId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + 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") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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.MongoDatabase", "MongoDatabase") + .WithMany() + .HasForeignKey("MongoDatabaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("DatabaseBindings"); + + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260518095816_AddDatabaseBindings.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260518095816_AddDatabaseBindings.cs new file mode 100644 index 0000000..274e129 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260518095816_AddDatabaseBindings.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddDatabaseBindings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DatabaseBindings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CnpgDatabaseId = table.Column(type: "uuid", nullable: true), + MongoDatabaseId = table.Column(type: "uuid", nullable: true), + AppDeploymentId = table.Column(type: "uuid", nullable: false), + KubernetesSecretName = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + SyncEnabled = table.Column(type: "boolean", nullable: false), + LastSyncedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DatabaseBindings"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260518142646_AddMongoClusterVaultSecret.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260518142646_AddMongoClusterVaultSecret.Designer.cs new file mode 100644 index 0000000..2aaf97a --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260518142646_AddMongoClusterVaultSecret.Designer.cs @@ -0,0 +1,2014 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260518142646_AddMongoClusterVaultSecret")] + partial class AddMongoClusterVaultSecret + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Instances") + .HasColumnType("integer"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RetentionDays") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoDatabaseId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServicePort") + .HasColumnType("integer"); + + b.Property("TlsCertificate") + .HasColumnType("text"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Members") + .HasColumnType("integer"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("RetentionDays") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectDomainName") + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("text"); + + b.Property("ProjectName") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserDomainName") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BucketName") + .HasColumnType("text"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("MongoDatabaseId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoClusterId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + 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") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoCluster"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("DatabaseBindings"); + + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260518142646_AddMongoClusterVaultSecret.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260518142646_AddMongoClusterVaultSecret.cs new file mode 100644 index 0000000..3340e43 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260518142646_AddMongoClusterVaultSecret.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddMongoClusterVaultSecret : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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); + } + + /// + 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"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260519191900_AddKeycloakComponentConfig.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260519191900_AddKeycloakComponentConfig.Designer.cs new file mode 100644 index 0000000..f23b744 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260519191900_AddKeycloakComponentConfig.Designer.cs @@ -0,0 +1,2221 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260519191900_AddKeycloakComponentConfig")] + partial class AddKeycloakComponentConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Instances") + .HasColumnType("integer"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RetentionDays") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoDatabaseId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServicePort") + .HasColumnType("integer"); + + b.Property("TlsCertificate") + .HasColumnType("text"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KeycloakRealmId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeycloakRealmId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("KeycloakBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdminUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AdminUsername") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClusterComponentId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountTheme") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("KeycloakComponentConfigId") + .HasColumnType("uuid"); + + b.Property("LinkedAppId") + .HasColumnType("uuid"); + + b.Property("LoginTheme") + .HasColumnType("text"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Members") + .HasColumnType("integer"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("RetentionDays") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectDomainName") + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("text"); + + b.Property("ProjectName") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserDomainName") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BucketName") + .HasColumnType("text"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("MongoDatabaseId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoClusterId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + 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") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + 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") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoCluster"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("DatabaseBindings"); + + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260519191900_AddKeycloakComponentConfig.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260519191900_AddKeycloakComponentConfig.cs new file mode 100644 index 0000000..56cebf0 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260519191900_AddKeycloakComponentConfig.cs @@ -0,0 +1,200 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddKeycloakComponentConfig : Migration + { + /// + 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(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + ClusterComponentId = table.Column(type: "uuid", nullable: false), + CnpgDatabaseId = table.Column(type: "uuid", nullable: true), + AdminUsername = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + AdminUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(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); + } + + /// + 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( + name: "KeycloakConnectionId", + table: "VaultSecrets", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "KeycloakConnections", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AdminPasswordSecretId = table.Column(type: "uuid", nullable: true), + KubernetesClusterId = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + AdminUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + AdminUsername = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(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); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/PostgresApplicationDbContextModelSnapshot.cs b/src/EntKube.Web/Data/Migrations/Postgres/PostgresApplicationDbContextModelSnapshot.cs index 264dac0..6020637 100644 --- a/src/EntKube.Web/Data/Migrations/Postgres/PostgresApplicationDbContextModelSnapshot.cs +++ b/src/EntKube.Web/Data/Migrations/Postgres/PostgresApplicationDbContextModelSnapshot.cs @@ -342,6 +342,9 @@ namespace EntKube.Web.Data.Migrations.Postgres .HasMaxLength(10) .HasColumnType("character varying(10)"); + b.Property("RetentionDays") + .HasColumnType("integer"); + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoDatabaseId") + .HasColumnType("uuid"); + + b.Property("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("Id") @@ -679,6 +722,140 @@ namespace EntKube.Web.Data.Migrations.Postgres b.ToTable("GroupMemberships"); }); + modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KeycloakRealmId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeycloakRealmId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("KeycloakBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdminUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AdminUsername") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClusterComponentId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountTheme") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("KeycloakComponentConfigId") + .HasColumnType("uuid"); + + b.Property("LinkedAppId") + .HasColumnType("uuid"); + + b.Property("LoginTheme") + .HasColumnType("text"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") @@ -720,6 +897,141 @@ namespace EntKube.Web.Data.Migrations.Postgres b.ToTable("KubernetesClusters"); }); + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Members") + .HasColumnType("integer"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("RetentionDays") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => { b.Property("Id") @@ -988,6 +1300,12 @@ namespace EntKube.Web.Data.Migrations.Postgres .HasMaxLength(253) .HasColumnType("character varying(253)"); + b.Property("MongoClusterId") + .HasColumnType("uuid"); + + b.Property("MongoDatabaseId") + .HasColumnType("uuid"); + b.Property("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"); diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260518072630_AddCnpgRetentionDays.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260518072630_AddCnpgRetentionDays.Designer.cs new file mode 100644 index 0000000..781f2e1 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260518072630_AddCnpgRetentionDays.Designer.cs @@ -0,0 +1,1725 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260518072630_AddCnpgRetentionDays")] + partial class AddCnpgRetentionDays + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HelmChartName") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmChartVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmRepoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("InstalledAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Instances") + .HasColumnType("int"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServicePort") + .HasColumnType("int"); + + b.Property("TlsCertificate") + .HasColumnType("nvarchar(max)"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectId") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("Username") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncEnabled") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BucketName") + .HasColumnType("nvarchar(max)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Endpoint") + .HasColumnType("nvarchar(max)"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Navigation("Backups"); + + b.Navigation("Databases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260518072630_AddCnpgRetentionDays.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260518072630_AddCnpgRetentionDays.cs new file mode 100644 index 0000000..6d424b8 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260518072630_AddCnpgRetentionDays.cs @@ -0,0 +1,176 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddCnpgRetentionDays : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CnpgDatabaseId", + table: "VaultSecrets", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateTable( + name: "CnpgClusters", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + KubernetesClusterId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: false), + Namespace = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: false), + PostgresVersion = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: false), + Instances = table.Column(type: "int", nullable: false), + StorageSize = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + StorageLinkId = table.Column(type: "uniqueidentifier", nullable: true), + BackupSchedule = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + RetentionDays = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + LastError = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(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(type: "uniqueidentifier", nullable: false), + CnpgClusterId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(253)", maxLength: 253, nullable: false), + Type = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + StartedAt = table.Column(type: "datetime2", nullable: false), + CompletedAt = table.Column(type: "datetime2", nullable: true), + SizeBytes = table.Column(type: "bigint", nullable: true), + LastError = table.Column(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(type: "uniqueidentifier", nullable: false), + CnpgClusterId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: false), + Owner = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: false), + Status = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260518082355_AddMongoEntities.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260518082355_AddMongoEntities.Designer.cs new file mode 100644 index 0000000..f4f501b --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260518082355_AddMongoEntities.Designer.cs @@ -0,0 +1,1927 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260518082355_AddMongoEntities")] + partial class AddMongoEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HelmChartName") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmChartVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmRepoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("InstalledAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Instances") + .HasColumnType("int"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServicePort") + .HasColumnType("int"); + + b.Property("TlsCertificate") + .HasColumnType("nvarchar(max)"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Members") + .HasColumnType("int"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectId") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("Username") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncEnabled") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BucketName") + .HasColumnType("nvarchar(max)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Endpoint") + .HasColumnType("nvarchar(max)"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("MongoDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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.MongoDatabase", "MongoDatabase") + .WithMany() + .HasForeignKey("MongoDatabaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Navigation("Backups"); + + b.Navigation("Databases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + 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.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260518082355_AddMongoEntities.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260518082355_AddMongoEntities.cs new file mode 100644 index 0000000..5306bb2 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260518082355_AddMongoEntities.cs @@ -0,0 +1,175 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddMongoEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MongoDatabaseId", + table: "VaultSecrets", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateTable( + name: "MongoClusters", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + KubernetesClusterId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: false), + Namespace = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: false), + MongoVersion = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + Members = table.Column(type: "int", nullable: false), + StorageSize = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + StorageLinkId = table.Column(type: "uniqueidentifier", nullable: true), + BackupSchedule = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + RetentionDays = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + LastError = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(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(type: "uniqueidentifier", nullable: false), + MongoClusterId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(253)", maxLength: 253, nullable: false), + Type = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + StartedAt = table.Column(type: "datetime2", nullable: false), + CompletedAt = table.Column(type: "datetime2", nullable: true), + SizeBytes = table.Column(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(type: "uniqueidentifier", nullable: false), + MongoClusterId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: false), + Owner = table.Column(type: "nvarchar(max)", nullable: false), + Status = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260518095821_AddDatabaseBindings.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260518095821_AddDatabaseBindings.Designer.cs new file mode 100644 index 0000000..c117dda --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260518095821_AddDatabaseBindings.Designer.cs @@ -0,0 +1,2004 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260518095821_AddDatabaseBindings")] + partial class AddDatabaseBindings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HelmChartName") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmChartVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmRepoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("InstalledAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Instances") + .HasColumnType("int"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("MongoDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServicePort") + .HasColumnType("int"); + + b.Property("TlsCertificate") + .HasColumnType("nvarchar(max)"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Members") + .HasColumnType("int"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectId") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("Username") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncEnabled") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BucketName") + .HasColumnType("nvarchar(max)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Endpoint") + .HasColumnType("nvarchar(max)"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("MongoDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + 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") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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.MongoDatabase", "MongoDatabase") + .WithMany() + .HasForeignKey("MongoDatabaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("DatabaseBindings"); + + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260518095821_AddDatabaseBindings.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260518095821_AddDatabaseBindings.cs new file mode 100644 index 0000000..e522b8d --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260518095821_AddDatabaseBindings.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddDatabaseBindings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DatabaseBindings", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + CnpgDatabaseId = table.Column(type: "uniqueidentifier", nullable: true), + MongoDatabaseId = table.Column(type: "uniqueidentifier", nullable: true), + AppDeploymentId = table.Column(type: "uniqueidentifier", nullable: false), + KubernetesSecretName = table.Column(type: "nvarchar(253)", maxLength: 253, nullable: false), + SyncEnabled = table.Column(type: "bit", nullable: false), + LastSyncedAt = table.Column(type: "datetime2", nullable: true), + CreatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DatabaseBindings"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260518142650_AddMongoClusterVaultSecret.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260518142650_AddMongoClusterVaultSecret.Designer.cs new file mode 100644 index 0000000..bc123d1 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260518142650_AddMongoClusterVaultSecret.Designer.cs @@ -0,0 +1,2016 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260518142650_AddMongoClusterVaultSecret")] + partial class AddMongoClusterVaultSecret + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HelmChartName") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmChartVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmRepoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("InstalledAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Instances") + .HasColumnType("int"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("MongoDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServicePort") + .HasColumnType("int"); + + b.Property("TlsCertificate") + .HasColumnType("nvarchar(max)"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Members") + .HasColumnType("int"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectId") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("Username") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncEnabled") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BucketName") + .HasColumnType("nvarchar(max)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Endpoint") + .HasColumnType("nvarchar(max)"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("MongoDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoClusterId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + 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") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoCluster"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("DatabaseBindings"); + + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260518142650_AddMongoClusterVaultSecret.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260518142650_AddMongoClusterVaultSecret.cs new file mode 100644 index 0000000..c9be9d2 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260518142650_AddMongoClusterVaultSecret.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddMongoClusterVaultSecret : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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); + } + + /// + 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"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260519191904_AddKeycloakComponentConfig.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260519191904_AddKeycloakComponentConfig.Designer.cs new file mode 100644 index 0000000..46c92a2 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260519191904_AddKeycloakComponentConfig.Designer.cs @@ -0,0 +1,2223 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260519191904_AddKeycloakComponentConfig")] + partial class AddKeycloakComponentConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HelmChartName") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmChartVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmRepoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("InstalledAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Instances") + .HasColumnType("int"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("MongoDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServicePort") + .HasColumnType("int"); + + b.Property("TlsCertificate") + .HasColumnType("nvarchar(max)"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KeycloakRealmId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("KeycloakRealmId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("KeycloakBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AdminUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("AdminUsername") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ClusterComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccountTheme") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("KeycloakComponentConfigId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAppId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginTheme") + .HasColumnType("nvarchar(max)"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Members") + .HasColumnType("int"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectId") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("Username") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncEnabled") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BucketName") + .HasColumnType("nvarchar(max)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Endpoint") + .HasColumnType("nvarchar(max)"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("MongoDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoClusterId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + 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") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + 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") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoCluster"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("DatabaseBindings"); + + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260519191904_AddKeycloakComponentConfig.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260519191904_AddKeycloakComponentConfig.cs new file mode 100644 index 0000000..5f04384 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260519191904_AddKeycloakComponentConfig.cs @@ -0,0 +1,200 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddKeycloakComponentConfig : Migration + { + /// + 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(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + ClusterComponentId = table.Column(type: "uniqueidentifier", nullable: false), + CnpgDatabaseId = table.Column(type: "uniqueidentifier", nullable: true), + AdminUsername = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + AdminUrl = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(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); + } + + /// + 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( + name: "KeycloakConnectionId", + table: "VaultSecrets", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateTable( + name: "KeycloakConnections", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + AdminPasswordSecretId = table.Column(type: "uniqueidentifier", nullable: true), + KubernetesClusterId = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + AdminUrl = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + AdminUsername = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(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); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/SqlServerApplicationDbContextModelSnapshot.cs b/src/EntKube.Web/Data/Migrations/SqlServer/SqlServerApplicationDbContextModelSnapshot.cs index ab8fb0d..47f65a4 100644 --- a/src/EntKube.Web/Data/Migrations/SqlServer/SqlServerApplicationDbContextModelSnapshot.cs +++ b/src/EntKube.Web/Data/Migrations/SqlServer/SqlServerApplicationDbContextModelSnapshot.cs @@ -266,6 +266,145 @@ namespace EntKube.Web.Data.Migrations.SqlServer b.ToTable("ClusterComponents"); }); + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Instances") + .HasColumnType("int"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + modelBuilder.Entity("EntKube.Web.Data.Customer", b => { b.Property("Id") @@ -314,6 +453,46 @@ namespace EntKube.Web.Data.Migrations.SqlServer b.ToTable("CustomerAccesses"); }); + modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("MongoDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("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("Id") @@ -544,6 +723,140 @@ namespace EntKube.Web.Data.Migrations.SqlServer b.ToTable("GroupMemberships"); }); + modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KeycloakRealmId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("KeycloakRealmId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("KeycloakBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AdminUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("AdminUsername") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ClusterComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccountTheme") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("KeycloakComponentConfigId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAppId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginTheme") + .HasColumnType("nvarchar(max)"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("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("Id") @@ -585,6 +898,141 @@ namespace EntKube.Web.Data.Migrations.SqlServer b.ToTable("KubernetesClusters"); }); + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Members") + .HasColumnType("int"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("RetentionDays") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => { b.Property("Id") @@ -832,6 +1280,9 @@ namespace EntKube.Web.Data.Migrations.SqlServer b.Property("AppId") .HasColumnType("uniqueidentifier"); + b.Property("CnpgDatabaseId") + .HasColumnType("uniqueidentifier"); + b.Property("ComponentId") .HasColumnType("uniqueidentifier"); @@ -850,6 +1301,12 @@ namespace EntKube.Web.Data.Migrations.SqlServer .HasMaxLength(253) .HasColumnType("nvarchar(253)"); + b.Property("MongoClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("MongoDatabaseId") + .HasColumnType("uniqueidentifier"); + b.Property("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"); diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260518072608_AddCnpgRetentionDays.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260518072608_AddCnpgRetentionDays.Designer.cs new file mode 100644 index 0000000..a74df76 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260518072608_AddCnpgRetentionDays.Designer.cs @@ -0,0 +1,1714 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260518072608_AddCnpgRetentionDays")] + partial class AddCnpgRetentionDays + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Instances") + .HasColumnType("INTEGER"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServicePort") + .HasColumnType("INTEGER"); + + b.Property("TlsCertificate") + .HasColumnType("TEXT"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TlsPrivateKey") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectDomainName") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProjectName") + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UserDomainName") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BucketName") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Navigation("Backups"); + + b.Navigation("Databases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260518072608_AddCnpgRetentionDays.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260518072608_AddCnpgRetentionDays.cs new file mode 100644 index 0000000..a58c32d --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260518072608_AddCnpgRetentionDays.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddCnpgRetentionDays : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RetentionDays", + table: "CnpgClusters", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RetentionDays", + table: "CnpgClusters"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260518082333_AddMongoEntities.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260518082333_AddMongoEntities.Designer.cs new file mode 100644 index 0000000..b3ff456 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260518082333_AddMongoEntities.Designer.cs @@ -0,0 +1,1916 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260518082333_AddMongoEntities")] + partial class AddMongoEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Instances") + .HasColumnType("INTEGER"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServicePort") + .HasColumnType("INTEGER"); + + b.Property("TlsCertificate") + .HasColumnType("TEXT"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TlsPrivateKey") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Members") + .HasColumnType("INTEGER"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectDomainName") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProjectName") + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UserDomainName") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BucketName") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("MongoDatabaseId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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.MongoDatabase", "MongoDatabase") + .WithMany() + .HasForeignKey("MongoDatabaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Navigation("Backups"); + + b.Navigation("Databases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + 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.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260518082333_AddMongoEntities.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260518082333_AddMongoEntities.cs new file mode 100644 index 0000000..ed2b65f --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260518082333_AddMongoEntities.cs @@ -0,0 +1,175 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddMongoEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MongoDatabaseId", + table: "VaultSecrets", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "MongoClusters", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + KubernetesClusterId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 63, nullable: false), + Namespace = table.Column(type: "TEXT", maxLength: 63, nullable: false), + MongoVersion = table.Column(type: "TEXT", maxLength: 20, nullable: false), + Members = table.Column(type: "INTEGER", nullable: false), + StorageSize = table.Column(type: "TEXT", maxLength: 20, nullable: false), + StorageLinkId = table.Column(type: "TEXT", nullable: true), + BackupSchedule = table.Column(type: "TEXT", maxLength: 100, nullable: true), + RetentionDays = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + LastError = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(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(type: "TEXT", nullable: false), + MongoClusterId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 253, nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + StartedAt = table.Column(type: "TEXT", nullable: false), + CompletedAt = table.Column(type: "TEXT", nullable: true), + SizeBytes = table.Column(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(type: "TEXT", nullable: false), + MongoClusterId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 63, nullable: false), + Owner = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260518095805_AddDatabaseBindings.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260518095805_AddDatabaseBindings.Designer.cs new file mode 100644 index 0000000..9c70b88 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260518095805_AddDatabaseBindings.Designer.cs @@ -0,0 +1,1993 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260518095805_AddDatabaseBindings")] + partial class AddDatabaseBindings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Instances") + .HasColumnType("INTEGER"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("MongoDatabaseId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServicePort") + .HasColumnType("INTEGER"); + + b.Property("TlsCertificate") + .HasColumnType("TEXT"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TlsPrivateKey") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Members") + .HasColumnType("INTEGER"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectDomainName") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProjectName") + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UserDomainName") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BucketName") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("MongoDatabaseId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + 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") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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.MongoDatabase", "MongoDatabase") + .WithMany() + .HasForeignKey("MongoDatabaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("DatabaseBindings"); + + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260518095805_AddDatabaseBindings.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260518095805_AddDatabaseBindings.cs new file mode 100644 index 0000000..7163d33 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260518095805_AddDatabaseBindings.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddDatabaseBindings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DatabaseBindings", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CnpgDatabaseId = table.Column(type: "TEXT", nullable: true), + MongoDatabaseId = table.Column(type: "TEXT", nullable: true), + AppDeploymentId = table.Column(type: "TEXT", nullable: false), + KubernetesSecretName = table.Column(type: "TEXT", maxLength: 253, nullable: false), + SyncEnabled = table.Column(type: "INTEGER", nullable: false), + LastSyncedAt = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DatabaseBindings"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260518142543_AddMongoClusterVaultSecret.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260518142543_AddMongoClusterVaultSecret.Designer.cs new file mode 100644 index 0000000..822085f --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260518142543_AddMongoClusterVaultSecret.Designer.cs @@ -0,0 +1,2005 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260518142543_AddMongoClusterVaultSecret")] + partial class AddMongoClusterVaultSecret + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Instances") + .HasColumnType("INTEGER"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("MongoDatabaseId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServicePort") + .HasColumnType("INTEGER"); + + b.Property("TlsCertificate") + .HasColumnType("TEXT"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TlsPrivateKey") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Members") + .HasColumnType("INTEGER"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectDomainName") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProjectName") + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UserDomainName") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BucketName") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("MongoDatabaseId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoClusterId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + 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") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoCluster"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("DatabaseBindings"); + + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260518142543_AddMongoClusterVaultSecret.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260518142543_AddMongoClusterVaultSecret.cs new file mode 100644 index 0000000..210575c --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260518142543_AddMongoClusterVaultSecret.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddMongoClusterVaultSecret : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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); + } + + /// + 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"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260519191909_AddKeycloakComponentConfig.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260519191909_AddKeycloakComponentConfig.Designer.cs new file mode 100644 index 0000000..e1bd54d --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260519191909_AddKeycloakComponentConfig.Designer.cs @@ -0,0 +1,2212 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260519191909_AddKeycloakComponentConfig")] + partial class AddKeycloakComponentConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Instances") + .HasColumnType("INTEGER"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("MongoDatabaseId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServicePort") + .HasColumnType("INTEGER"); + + b.Property("TlsCertificate") + .HasColumnType("TEXT"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TlsPrivateKey") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KeycloakRealmId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeycloakRealmId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("KeycloakBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AdminUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("AdminUsername") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ClusterComponentId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountTheme") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("KeycloakComponentConfigId") + .HasColumnType("TEXT"); + + b.Property("LinkedAppId") + .HasColumnType("TEXT"); + + b.Property("LoginTheme") + .HasColumnType("TEXT"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Members") + .HasColumnType("INTEGER"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectDomainName") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProjectName") + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UserDomainName") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BucketName") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("MongoDatabaseId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("MongoClusterId"); + + b.HasIndex("MongoDatabaseId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + 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") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + 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") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + 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") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .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"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("MongoCluster"); + + b.Navigation("MongoDatabase"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("DatabaseBindings"); + + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + 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"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260519191909_AddKeycloakComponentConfig.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260519191909_AddKeycloakComponentConfig.cs new file mode 100644 index 0000000..8c2080f --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260519191909_AddKeycloakComponentConfig.cs @@ -0,0 +1,200 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddKeycloakComponentConfig : Migration + { + /// + 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(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + ClusterComponentId = table.Column(type: "TEXT", nullable: false), + CnpgDatabaseId = table.Column(type: "TEXT", nullable: true), + AdminUsername = table.Column(type: "TEXT", maxLength: 100, nullable: false), + AdminUrl = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAt = table.Column(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); + } + + /// + 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( + name: "KeycloakConnectionId", + table: "VaultSecrets", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "KeycloakConnections", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AdminPasswordSecretId = table.Column(type: "TEXT", nullable: true), + KubernetesClusterId = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + AdminUrl = table.Column(type: "TEXT", maxLength: 500, nullable: false), + AdminUsername = table.Column(type: "TEXT", maxLength: 100, nullable: false), + CreatedAt = table.Column(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); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/ApplicationDbContextModelSnapshot.cs b/src/EntKube.Web/Data/Migrations/Sqlite/ApplicationDbContextModelSnapshot.cs index b84797c..228e37e 100644 --- a/src/EntKube.Web/Data/Migrations/Sqlite/ApplicationDbContextModelSnapshot.cs +++ b/src/EntKube.Web/Data/Migrations/Sqlite/ApplicationDbContextModelSnapshot.cs @@ -337,6 +337,9 @@ namespace EntKube.Web.Data.Migrations.Sqlite .HasMaxLength(10) .HasColumnType("TEXT"); + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("MongoDatabaseId") + .HasColumnType("TEXT"); + + b.Property("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("Id") @@ -674,6 +717,140 @@ namespace EntKube.Web.Data.Migrations.Sqlite b.ToTable("GroupMemberships"); }); + modelBuilder.Entity("EntKube.Web.Data.KeycloakBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KeycloakRealmId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeycloakRealmId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("KeycloakBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KeycloakComponentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AdminUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("AdminUsername") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ClusterComponentId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountTheme") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("KeycloakComponentConfigId") + .HasColumnType("TEXT"); + + b.Property("LinkedAppId") + .HasColumnType("TEXT"); + + b.Property("LoginTheme") + .HasColumnType("TEXT"); + + b.Property("RealmName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("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("Id") @@ -715,6 +892,141 @@ namespace EntKube.Web.Data.Migrations.Sqlite b.ToTable("KubernetesClusters"); }); + modelBuilder.Entity("EntKube.Web.Data.MongoBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.MongoCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Members") + .HasColumnType("INTEGER"); + + b.Property("MongoVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("RetentionDays") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MongoClusterId", "Name") + .IsUnique(); + + b.ToTable("MongoDatabases"); + }); + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => { b.Property("Id") @@ -983,6 +1295,12 @@ namespace EntKube.Web.Data.Migrations.Sqlite .HasMaxLength(253) .HasColumnType("TEXT"); + b.Property("MongoClusterId") + .HasColumnType("TEXT"); + + b.Property("MongoDatabaseId") + .HasColumnType("TEXT"); + b.Property("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"); diff --git a/src/EntKube.Web/Data/MongoCluster.cs b/src/EntKube.Web/Data/MongoCluster.cs new file mode 100644 index 0000000..1786a04 --- /dev/null +++ b/src/EntKube.Web/Data/MongoCluster.cs @@ -0,0 +1,186 @@ +namespace EntKube.Web.Data; + +/// +/// The lifecycle status of a managed Percona MongoDB cluster. Tracks where the +/// cluster is in the provisioning/operational/teardown cycle. +/// +public enum MongoClusterStatus +{ + Creating, + Running, + Upgrading, + Restoring, + Failed, + Deleting +} + +/// +/// 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. +/// +public class MongoCluster +{ + public Guid Id { get; set; } + + /// + /// The tenant that owns this cluster. + /// + public Guid TenantId { get; set; } + + /// + /// The Kubernetes cluster where this MongoDB cluster runs. + /// Must have the Percona MongoDB operator installed. + /// + public Guid KubernetesClusterId { get; set; } + + /// + /// The name of the PerconaServerMongoDB resource in Kubernetes (metadata.name). + /// Lowercase, DNS-safe, max 63 chars. + /// + public required string Name { get; set; } + + /// + /// The Kubernetes namespace where the cluster lives. + /// + public required string Namespace { get; set; } + + /// + /// The MongoDB version (e.g. "8.0", "7.0", "6.0"). + /// Maps to the Percona Server for MongoDB container image tag. + /// + public required string MongoVersion { get; set; } + + /// + /// Number of replica set members (1 = standalone, 3 = HA with automatic failover). + /// + public int Members { get; set; } = 3; + + /// + /// PVC storage size for each member (e.g. "10Gi", "50Gi"). + /// + public required string StorageSize { get; set; } + + /// + /// 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). + /// + public Guid? StorageLinkId { get; set; } + + /// + /// Cron schedule for automated backups (e.g. "0 2 * * *" for daily at 2 AM). + /// Null means no scheduled backups — only on-demand. + /// + public string? BackupSchedule { get; set; } + + /// + /// Number of days to retain backups. Percona Backup for MongoDB uses this + /// to auto-delete old backups. Default is 30 days. + /// + public int RetentionDays { get; set; } = 30; + + /// + /// Current lifecycle status of the cluster. + /// + public MongoClusterStatus Status { get; set; } = MongoClusterStatus.Creating; + + /// + /// The last error encountered during a lifecycle operation. + /// Cleared on successful operations. + /// + 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 Databases { get; set; } = []; + public ICollection Backups { get; set; } = []; +} + +/// +/// The status of a database within a managed MongoDB cluster. +/// +public enum MongoDatabaseStatus +{ + Creating, + Ready, + Failed +} + +/// +/// 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. +/// +public class MongoDatabase +{ + public Guid Id { get; set; } + public Guid MongoClusterId { get; set; } + + /// + /// The database name in MongoDB. + /// + public required string Name { get; set; } + + /// + /// The username that owns this database (has readWrite role). + /// + 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 DatabaseBindings { get; set; } = []; +} + +/// +/// The type of a MongoDB backup — scheduled or triggered on-demand. +/// +public enum MongoBackupType +{ + Scheduled, + OnDemand +} + +/// +/// The status of a MongoDB backup operation. +/// +public enum MongoBackupStatus +{ + Running, + Completed, + Failed +} + +/// +/// A backup record for a managed Percona MongoDB cluster. +/// Corresponds to a PerconaServerMongoDBBackup CRD in Kubernetes. +/// +public class MongoBackup +{ + public Guid Id { get; set; } + public Guid MongoClusterId { get; set; } + + /// + /// The name of the backup CR in Kubernetes. + /// + 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!; +} diff --git a/src/EntKube.Web/Data/VaultSecret.cs b/src/EntKube.Web/Data/VaultSecret.cs index 958d142..df14c5f 100644 --- a/src/EntKube.Web/Data/VaultSecret.cs +++ b/src/EntKube.Web/Data/VaultSecret.cs @@ -56,6 +56,16 @@ public class VaultSecret /// public Guid? CnpgDatabaseId { get; set; } + /// + /// If set, this secret belongs to a MongoDB database (connection credentials). + /// + public Guid? MongoDatabaseId { get; set; } + + /// + /// If set, this secret belongs to a managed MongoDB cluster (e.g. the admin password). + /// + public Guid? MongoClusterId { get; set; } + /// /// When true, this secret will be synced to Kubernetes as a Secret resource. /// @@ -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; } } diff --git a/src/EntKube.Web/Program.cs b/src/EntKube.Web/Program.cs index 25716e7..1e0a03e 100644 --- a/src/EntKube.Web/Program.cs +++ b/src/EntKube.Web/Program.cs @@ -108,10 +108,12 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); WebApplication app = builder.Build(); diff --git a/src/EntKube.Web/Services/CnpgService.cs b/src/EntKube.Web/Services/CnpgService.cs index 6ed55c8..50d8e28 100644 --- a/src/EntKube.Web/Services/CnpgService.cs +++ b/src/EntKube.Web/Services/CnpgService.cs @@ -37,6 +37,7 @@ public class CnpgService( string storageSize, Guid? storageLinkId, string? backupSchedule, + int retentionDays = 30, CancellationToken ct = default) { using ApplicationDbContext db = dbFactory.CreateDbContext(); @@ -83,6 +84,7 @@ public class CnpgService( StorageSize = storageSize, StorageLinkId = storageLinkId, BackupSchedule = backupSchedule, + RetentionDays = retentionDays, Status = CnpgClusterStatus.Creating }; @@ -97,47 +99,62 @@ public class CnpgService( if (storageLink is not null) { s3SecretName = $"{name}-s3-credentials"; - - await EnsureStorageSecretsInK8sAsync( - tenantId, storageLink, s3SecretName, ns, ct); } // Generate and apply the CNPG Cluster manifest. string manifest = BuildClusterManifest(cnpgCluster, storageLink, s3SecretName); - // Ensure the target namespace exists before applying CNPG resources. + // Ensure the target namespace exists before applying any resources. await k8sFactory.EnsureNamespaceAsync(ns, k8sCluster.Kubeconfig!, ct); - await k8sFactory.ApplyManifestAsync(manifest, k8sCluster.Kubeconfig!, ct); - // If backup storage is configured, apply the ObjectStore CRD separately. - // The ObjectStore requires the Barman Cloud Plugin to be installed — if it - // isn't, we still create the cluster but surface a clear error about backups. + // Now that the namespace exists, create the S3 credentials Secret. if (storageLink is not null && s3SecretName is not null) { - string objectStoreManifest = BuildObjectStoreManifest(name, ns, storageLink, s3SecretName); + await EnsureStorageSecretsInK8sAsync( + tenantId, storageLink, s3SecretName, ns, k8sCluster.Kubeconfig!, ct); + } + + await k8sFactory.ApplyManifestAsync(manifest, k8sCluster.Kubeconfig!, ct); + + // If backup storage is configured, apply the ObjectStore then the ScheduledBackup + // as separate calls so each failure surfaces a precise error. + + if (storageLink is not null && s3SecretName is not null) + { + string objectStoreManifest = BuildObjectStoreManifest(name, ns, storageLink, s3SecretName, retentionDays); try { await k8sFactory.ApplyManifestAsync(objectStoreManifest, k8sCluster.Kubeconfig!, ct); } - catch (InvalidOperationException) + catch (InvalidOperationException ex) when (ex.Message.Contains("no matches for kind")) { throw new InvalidOperationException( - "Cluster created but backup configuration failed — the Barman Cloud Plugin " + - "is not installed. Install 'barman-cloud-plugin' from the component catalog, " + - "then re-save the cluster to apply backup settings."); + "Cluster created but the ObjectStore could not be applied — the Barman Cloud Plugin " + + "CRD (barmancloud.cnpg.io/ObjectStore) is not available. Ensure the Barman Cloud " + + "Plugin toggle is enabled on the CloudNativePG component and CNPG ≥ 1.24 is " + + "installed. Then delete and recreate the cluster."); } - } - // If a backup schedule is configured, append a ScheduledBackup resource. + if (!string.IsNullOrWhiteSpace(backupSchedule)) + { + string scheduledBackupManifest = BuildScheduledBackupManifest(name, ns, backupSchedule); - if (!string.IsNullOrWhiteSpace(backupSchedule) && storageLink is not null) - { - string scheduledBackupManifest = BuildScheduledBackupManifest(name, ns, backupSchedule); - await k8sFactory.ApplyManifestAsync(scheduledBackupManifest, k8sCluster.Kubeconfig!, ct); + try + { + await k8sFactory.ApplyManifestAsync(scheduledBackupManifest, k8sCluster.Kubeconfig!, ct); + } + catch (InvalidOperationException ex) + { + throw new InvalidOperationException( + $"Cluster and ObjectStore created but the ScheduledBackup could not be applied. " + + $"Ensure CNPG ≥ 1.24 is installed and the backup schedule is a valid 6-field " + + $"cron expression (e.g. \"0 0 2 * * *\"). kubectl error: {ex.Message}"); + } + } } return cnpgCluster; @@ -321,12 +338,12 @@ public class CnpgService( string s3SecretName = $"{tempName}-s3-credentials"; await EnsureStorageSecretsInK8sAsync( - tenantId, source.StorageLink, s3SecretName, source.Namespace, ct); + tenantId, source.StorageLink, s3SecretName, source.Namespace, source.KubernetesCluster.Kubeconfig!, ct); // Use current time as recovery target — we want the very latest state. string restoreManifest = BuildRestoreManifest(upgraded, source, DateTime.UtcNow, s3SecretName); - restoreManifest += "\n---\n" + BuildObjectStoreManifest(tempName, source.Namespace, source.StorageLink, s3SecretName); + restoreManifest += "\n---\n" + BuildObjectStoreManifest(tempName, source.Namespace, source.StorageLink, s3SecretName, source.RetentionDays); await k8sFactory.ApplyManifestAsync(restoreManifest, source.KubernetesCluster.Kubeconfig!, ct); // Step 3: Delete the old cluster from Kubernetes to free the name. @@ -360,7 +377,7 @@ public class CnpgService( string s3SecretFinal = $"{source.Name}-s3-credentials"; string finalManifest = BuildClusterManifest(upgraded, source.StorageLink, s3SecretFinal); - finalManifest += "\n---\n" + BuildObjectStoreManifest(source.Name, source.Namespace, source.StorageLink, s3SecretFinal); + finalManifest += "\n---\n" + BuildObjectStoreManifest(source.Name, source.Namespace, source.StorageLink, s3SecretFinal, source.RetentionDays); await k8sFactory.ApplyManifestAsync(finalManifest, source.KubernetesCluster.Kubeconfig!, ct); return upgraded; @@ -368,6 +385,48 @@ public class CnpgService( // ──────── Backup & Restore ──────── + /// + /// Applies (or re-applies) the ScheduledBackup CR for an existing cluster. + /// If is provided it is saved to the cluster record + /// before applying — use this to configure a schedule on a cluster that was + /// created without one. If omitted, the stored BackupSchedule is used. + /// Safe to call repeatedly — kubectl apply is idempotent. + /// + public async Task ApplyScheduledBackupAsync( + Guid tenantId, Guid cnpgClusterId, string? schedule = null, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster cnpg = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG cluster not found."); + + if (!string.IsNullOrWhiteSpace(schedule)) + { + cnpg.BackupSchedule = schedule; + await db.SaveChangesAsync(ct); + } + + if (string.IsNullOrWhiteSpace(cnpg.BackupSchedule)) + { + throw new InvalidOperationException( + "This cluster has no backup schedule configured."); + } + + string manifest = BuildScheduledBackupManifest(cnpg.Name, cnpg.Namespace, cnpg.BackupSchedule); + + try + { + await k8sFactory.ApplyManifestAsync(manifest, cnpg.KubernetesCluster.Kubeconfig!, ct); + } + catch (InvalidOperationException ex) + { + throw new InvalidOperationException( + $"Failed to apply ScheduledBackup: {ex.Message}"); + } + } + /// /// Triggers an on-demand backup of a CNPG cluster. Creates a Backup CR /// in Kubernetes that tells Barman to take a base backup to the S3 bucket. @@ -378,6 +437,7 @@ public class CnpgService( CnpgCluster cnpg = await db.CnpgClusters .Include(c => c.KubernetesCluster) + .Include(c => c.StorageLink) .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct) ?? throw new InvalidOperationException("CNPG cluster not found."); @@ -387,6 +447,20 @@ public class CnpgService( "Backup storage is not configured for this cluster. Assign an S3 bucket first."); } + string kubeconfig = cnpg.KubernetesCluster.Kubeconfig!; + + // Re-apply the cluster manifest before every on-demand backup. This is a + // best-effort idempotent reconcile that picks up any config changes (e.g. + // archive_timeout) without requiring a separate upgrade operation. + // Ignored on failure — the backup itself is the critical operation. + try + { + string s3SecretName = $"{cnpg.Name}-s3-credentials"; + await k8sFactory.ApplyManifestAsync( + BuildClusterManifest(cnpg, cnpg.StorageLink, s3SecretName), kubeconfig, ct); + } + catch { /* non-fatal */ } + // Generate a unique backup name with timestamp. string backupName = $"{cnpg.Name}-{DateTime.UtcNow:yyyyMMdd-HHmmss}"; @@ -406,19 +480,23 @@ public class CnpgService( // Apply the Backup CR to trigger Barman. string manifest = BuildBackupManifest(backupName, cnpg.Name, cnpg.Namespace); - await k8sFactory.ApplyManifestAsync(manifest, cnpg.KubernetesCluster.Kubeconfig!, ct); + await k8sFactory.ApplyManifestAsync(manifest, kubeconfig, ct); return backup; } /// - /// Performs a point-in-time restore by creating a new CNPG cluster that bootstraps - /// from the source cluster's Barman backup. The new cluster recovers WALs up to - /// the specified target time, giving you a consistent snapshot of that moment. + /// Performs a point-in-time restore into a new CNPG cluster, mirroring the MongoDB + /// restore pattern: (1) create an empty cluster so it appears in the UI immediately, + /// (2) transition to Restoring, (3) replace the empty Cluster CR with a recovery CR + /// that bootstraps from the source cluster's Barman backup up to the target time. + /// + /// When is provided the recovery references that specific + /// CNPG Backup CR directly — simpler and more reliable than using externalClusters. /// public async Task RestoreAsync( Guid tenantId, Guid sourceCnpgClusterId, string newClusterName, - DateTime targetTime, CancellationToken ct = default) + DateTime? targetTime = null, string? barmanBackupId = null, CancellationToken ct = default) { using ApplicationDbContext db = dbFactory.CreateDbContext(); @@ -434,7 +512,14 @@ public class CnpgService( "Source cluster has no backup storage configured. Cannot restore without backups."); } - // Create a new cluster record for the restored instance. + string kubeconfig = source.KubernetesCluster.Kubeconfig!; + + // Step 1: Persist the new cluster record immediately so it appears in the UI. + // Skip the empty-cluster-then-delete pattern: CNPG clusters have finalizers, so + // a delete does not complete instantly. Applying a restore CR against a name that + // is still being finalized results in an update that CNPG discards, silently + // leaving the cluster in a broken state. Applying the recovery CR directly on a + // fresh name is the correct CNPG bootstrap.recovery pattern. CnpgCluster restored = new() { @@ -448,27 +533,101 @@ public class CnpgService( StorageSize = source.StorageSize, StorageLinkId = source.StorageLinkId, BackupSchedule = source.BackupSchedule, + RetentionDays = source.RetentionDays, Status = CnpgClusterStatus.Restoring }; db.CnpgClusters.Add(restored); await db.SaveChangesAsync(ct); - // Generate a recovery manifest that bootstraps from the source's Barman backup - // using the Barman Cloud Plugin. We create an ObjectStore for the new cluster - // so it can continue WAL archiving after recovery completes. + await k8sFactory.EnsureNamespaceAsync(source.Namespace, kubeconfig, ct); + // Set up the new cluster's S3 credentials and ObjectStore so they exist + // before the recovery Cluster CR is applied (CNPG validates the ObjectStore + // reference at reconcile time). string s3SecretName = $"{newClusterName}-s3-credentials"; await EnsureStorageSecretsInK8sAsync( - tenantId, source.StorageLink, s3SecretName, source.Namespace, ct); + tenantId, source.StorageLink, s3SecretName, source.Namespace, kubeconfig, ct); + await k8sFactory.ApplyManifestAsync( + BuildObjectStoreManifest(newClusterName, source.Namespace, source.StorageLink, s3SecretName, source.RetentionDays), + kubeconfig, ct); - string manifest = BuildRestoreManifest(restored, source, targetTime, s3SecretName); - manifest += "\n---\n" + BuildObjectStoreManifest(newClusterName, source.Namespace, source.StorageLink, s3SecretName); - await k8sFactory.ApplyManifestAsync(manifest, source.KubernetesCluster.Kubeconfig!, ct); + // Ensure the SOURCE cluster's ObjectStore and S3 credentials are present so + // the recovery externalClusters reference can reach the backup archive. + string sourceS3SecretName = $"{source.Name}-s3-credentials"; + await EnsureStorageSecretsInK8sAsync( + tenantId, source.StorageLink, sourceS3SecretName, source.Namespace, kubeconfig, ct); + await k8sFactory.ApplyManifestAsync( + BuildObjectStoreManifest(source.Name, source.Namespace, source.StorageLink, sourceS3SecretName, source.RetentionDays), + kubeconfig, ct); + + // Step 2: Apply the recovery Cluster CR directly. CNPG's bootstrap.recovery is + // only evaluated on initial cluster creation, so this must be applied to a name + // that has no existing Cluster CR or PVCs in K8s. + await k8sFactory.ApplyManifestAsync( + BuildRestoreManifest(restored, source, targetTime, s3SecretName, barmanBackupId: barmanBackupId), + kubeconfig, ct); return restored; } + /// + /// Restores a CNPG cluster in-place from its own Barman backup. Deletes the current + /// Kubernetes Cluster resource (preserving the ObjectStore and S3 data), then + /// recreates it with a recovery bootstrap so CNPG recovers WALs up to targetTime. + /// The database record is reused — only the status changes to Restoring. + /// + public async Task RestoreInPlaceAsync( + Guid tenantId, Guid cnpgClusterId, DateTime targetTime, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster source = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .Include(c => c.StorageLink) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG cluster not found."); + + if (source.StorageLink is null) + { + throw new InvalidOperationException( + "Cluster has no backup storage configured. Cannot restore without backups."); + } + + // Delete only the Cluster CRD, not the ObjectStore — the recovery bootstrap + // references the existing ObjectStore to locate WAL archives in S3. + try + { + await k8sFactory.DeleteManifestAsync( + "clusters.postgresql.cnpg.io", source.Name, source.Namespace, + source.KubernetesCluster.Kubeconfig!, ct); + } + catch { } + + source.Status = CnpgClusterStatus.Restoring; + await db.SaveChangesAsync(ct); + + string s3SecretName = $"{source.Name}-s3-credentials"; + await EnsureStorageSecretsInK8sAsync( + tenantId, source.StorageLink, s3SecretName, source.Namespace, + source.KubernetesCluster.Kubeconfig!, ct); + + // Re-apply the ObjectStore before the Cluster so CNPG finds it immediately. + // The source ObjectStore already exists, so this is effectively a no-op update, + // but applying it first ensures the Cluster CRD is never processed without it. + await k8sFactory.ApplyManifestAsync( + BuildObjectStoreManifest(source.Name, source.Namespace, source.StorageLink, s3SecretName, source.RetentionDays), + source.KubernetesCluster.Kubeconfig!, ct); + + // Use "backup-source" as the external cluster alias so the manifest does not + // reference the cluster by its own name (CNPG rejects self-referential bootstraps). + await k8sFactory.ApplyManifestAsync( + BuildRestoreManifest(source, source, targetTime, s3SecretName, sourceAlias: "backup-source"), + source.KubernetesCluster.Kubeconfig!, ct); + + return source; + } + // ──────── Database Management ──────── /// @@ -554,25 +713,287 @@ public class CnpgService( ?? throw new InvalidOperationException("Database not found."); // Drop the database and role from PostgreSQL. + // If this fails (e.g. database never created, cluster unreachable), we still + // proceed with removing the record so the user isn't stuck. - string sql = $""" - DROP DATABASE IF EXISTS "{database.Name}"; - DROP ROLE IF EXISTS "{database.Owner}"; - """; + try + { + string sql = $""" + DROP DATABASE IF EXISTS "{database.Name}"; + DROP ROLE IF EXISTS "{database.Owner}"; + """; - await k8sFactory.ExecuteSqlAsync( - cnpg.Name, cnpg.Namespace, sql, cnpg.KubernetesCluster.Kubeconfig!, ct); + await k8sFactory.ExecuteSqlAsync( + cnpg.Name, cnpg.Namespace, sql, cnpg.KubernetesCluster.Kubeconfig!, ct); + } + catch (InvalidOperationException) + { + // SQL execution failed — the database may never have been created + // (e.g. stuck in "Creating" state). Continue with cleanup. + } + + // Delete the K8s Secret from the cluster's own namespace and from every + // bound app namespace. Failures are swallowed so a missing or unreachable + // cluster doesn't block the cleanup. + + string primarySecretName = $"{cnpg.Name}-{database.Name}-credentials"; + string kubeconfig = cnpg.KubernetesCluster.Kubeconfig!; + + try + { + await k8sFactory.DeleteManifestAsync("Secret", primarySecretName, cnpg.Namespace, kubeconfig, ct); + } + catch { } + + List bindings = await db.DatabaseBindings + .Include(b => b.AppDeployment) + .ThenInclude(d => d.Cluster) + .Where(b => b.CnpgDatabaseId == databaseId) + .ToListAsync(ct); + + foreach (DatabaseBinding binding in bindings) + { + try + { + await k8sFactory.DeleteManifestAsync( + "Secret", + binding.KubernetesSecretName, + binding.AppDeployment.Namespace, + binding.AppDeployment.Cluster.Kubeconfig!, + ct); + } + catch { } + } // Remove vault secrets. await DeleteDatabaseSecretsAsync(tenantId, database.Id, ct); - // Remove the database record. + // Remove the database record (cascades to DatabaseBinding rows). db.CnpgDatabases.Remove(database); await db.SaveChangesAsync(ct); } + // ──────── Database Credentials ──────── + + /// + /// Retrieves the decrypted connection credentials for a database. Returns a + /// dictionary with keys HOST, PORT, DATABASE, USERNAME, PASSWORD — everything + /// an application needs to connect. These come from the vault, never stored + /// in plaintext outside of it. + /// + public async Task> GetDatabaseCredentialsAsync( + Guid tenantId, Guid databaseId, CancellationToken ct = default) + { + List secrets = await vaultService.GetCnpgDatabaseSecretsAsync(tenantId, databaseId, ct); + + Dictionary credentials = new(); + + foreach (VaultSecret secret in secrets) + { + string? value = await vaultService.GetSecretValueByIdAsync(secret.Id, ct); + + if (value is not null) + { + credentials[secret.Name] = value; + } + } + + return credentials; + } + + /// + /// Pushes the database credentials to Kubernetes as a Secret in the cluster's + /// namespace. This creates (or updates) a single opaque Secret containing all + /// connection fields: HOST, PORT, DATABASE, USERNAME, PASSWORD. Applications + /// can then mount this Secret as env vars or volume to connect to the database. + /// + public async Task SyncDatabaseCredentialsToK8sAsync( + Guid tenantId, Guid cnpgClusterId, Guid databaseId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster cnpg = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG cluster not found."); + + CnpgDatabase database = await db.CnpgDatabases + .FirstOrDefaultAsync(d => d.Id == databaseId && d.CnpgClusterId == cnpg.Id, ct) + ?? throw new InvalidOperationException("Database not found."); + + Dictionary credentials = await GetDatabaseCredentialsAsync(tenantId, databaseId, ct); + + if (credentials.Count == 0) + { + throw new InvalidOperationException("No credentials found in the vault for this database."); + } + + // Sync to the primary namespace (the cluster's own namespace). + + string primarySecretName = $"{cnpg.Name}-{database.Name}-credentials"; + string primaryKubeconfig = cnpg.KubernetesCluster.Kubeconfig!; + + await ApplyCredentialSecretAsync( + credentials, primarySecretName, cnpg.Namespace, primaryKubeconfig, ct); + + // Mark vault secrets as synced so the UI reflects the current state. + + List vaultSecrets = await vaultService.GetCnpgDatabaseSecretsAsync(tenantId, databaseId, ct); + + foreach (VaultSecret secret in vaultSecrets) + { + await vaultService.ConfigureKubernetesSyncAsync(secret.Id, true, primarySecretName, cnpg.Namespace, ct); + } + + // Propagate to every app deployment bound to this database. + + List bindings = await db.DatabaseBindings + .Include(b => b.AppDeployment) + .ThenInclude(d => d.Cluster) + .Where(b => b.CnpgDatabaseId == databaseId && b.SyncEnabled) + .ToListAsync(ct); + + foreach (DatabaseBinding binding in bindings) + { + string kubeconfig = binding.AppDeployment.Cluster.Kubeconfig!; + string ns = binding.AppDeployment.Namespace; + + await k8sFactory.EnsureNamespaceAsync(ns, kubeconfig, ct); + await ApplyCredentialSecretAsync(credentials, binding.KubernetesSecretName, ns, kubeconfig, ct); + + binding.LastSyncedAt = DateTime.UtcNow; + } + + await db.SaveChangesAsync(ct); + } + + /// + /// Writes (or overwrites) a Kubernetes Opaque Secret with the supplied + /// credential key/value pairs into the given namespace. + /// + private async Task ApplyCredentialSecretAsync( + Dictionary credentials, + string secretName, string ns, string kubeconfig, + CancellationToken ct) + { + StringBuilder sb = new(); + sb.AppendLine("apiVersion: v1"); + sb.AppendLine("kind: Secret"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {secretName}"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("type: Opaque"); + sb.AppendLine("data:"); + + foreach (KeyValuePair kvp in credentials) + { + string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(kvp.Value)); + sb.AppendLine($" {kvp.Key}: {encoded}"); + } + + await k8sFactory.ApplyManifestAsync(sb.ToString(), kubeconfig, ct); + } + + // ──────── Password rotation ──────── + + /// + /// Rotates the password for a database owner role. Generates a new random + /// password, applies it via ALTER ROLE on the primary, updates the vault, + /// then syncs credentials to every namespace that holds a copy — the cluster's + /// own namespace and all bound app deployments. + /// + public async Task RotateDatabasePasswordAsync( + Guid tenantId, Guid cnpgClusterId, Guid databaseId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster cnpg = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG cluster not found."); + + CnpgDatabase database = await db.CnpgDatabases + .FirstOrDefaultAsync(d => d.Id == databaseId && d.CnpgClusterId == cnpg.Id, ct) + ?? throw new InvalidOperationException("Database not found."); + + string newPassword = GeneratePassword(); + + await k8sFactory.ExecuteSqlAsync( + cnpg.Name, cnpg.Namespace, + $"ALTER ROLE \"{database.Owner}\" PASSWORD '{newPassword}';", + cnpg.KubernetesCluster.Kubeconfig!, ct); + + string k8sSecretName = $"{cnpg.Name}-{database.Name}-credentials"; + + await vaultService.SetCnpgDatabaseSecretAsync( + tenantId, databaseId, "PASSWORD", newPassword, k8sSecretName, cnpg.Namespace, ct); + + // Re-sync to the cluster namespace and all bound app namespaces. + await SyncDatabaseCredentialsToK8sAsync(tenantId, cnpgClusterId, databaseId, ct); + } + + // ──────── DatabaseBinding management ──────── + + /// + /// Returns all database bindings for a specific app deployment, with their + /// CNPG and MongoDB database navigation properties populated. + /// + public async Task> GetDatabaseBindingsAsync( + Guid appDeploymentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.DatabaseBindings + .Include(b => b.CnpgDatabase) + .ThenInclude(d => d!.CnpgCluster) + .Include(b => b.MongoDatabase) + .ThenInclude(d => d!.MongoCluster) + .Where(b => b.AppDeploymentId == appDeploymentId) + .OrderBy(b => b.CreatedAt) + .ToListAsync(ct); + } + + /// + /// Creates a binding between a CNPG database and an app deployment. Does not + /// sync immediately — call SyncDatabaseCredentialsToK8sAsync to push credentials. + /// + public async Task AddCnpgDatabaseBindingAsync( + Guid appDeploymentId, Guid cnpgDatabaseId, string kubernetesSecretName, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + DatabaseBinding binding = new() + { + Id = Guid.NewGuid(), + AppDeploymentId = appDeploymentId, + CnpgDatabaseId = cnpgDatabaseId, + KubernetesSecretName = kubernetesSecretName + }; + + db.DatabaseBindings.Add(binding); + await db.SaveChangesAsync(ct); + return binding; + } + + /// + /// Removes a database binding. Does not delete the Kubernetes Secret from the + /// app's namespace — that must be cleaned up manually if desired. + /// + public async Task RemoveDatabaseBindingAsync(Guid bindingId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + DatabaseBinding binding = await db.DatabaseBindings + .FirstOrDefaultAsync(b => b.Id == bindingId, ct) + ?? throw new InvalidOperationException("Binding not found."); + + db.DatabaseBindings.Remove(binding); + await db.SaveChangesAsync(ct); + } + // ──────── Queries ──────── /// @@ -634,7 +1055,8 @@ public class CnpgService( CnpgClusterDetail detail = new() { Cluster = cluster, - Phase = "Querying..." + Phase = "Querying...", + Backups = cluster.Backups.OrderByDescending(b => b.StartedAt).ToList() }; try @@ -654,6 +1076,22 @@ public class CnpgService( $"cnpg.io/cluster={cluster.Name}", ct); detail.Pods = ParsePodList(podsJson, detail.CurrentPrimary); + + // Reconcile the persisted status with the live K8s state. + // If the cluster reports healthy but we still say "Creating", update it. + + await ReconcileClusterStatusAsync(cluster, detail, ct); + + // Push any code-level config changes (e.g. archive_timeout, target:primary on + // ScheduledBackup) to the K8s resources. kubectl apply is idempotent — when the + // manifest is already in sync this is a cheap no-op GET with no write. Best-effort: + // a failure here must not prevent the detail view from loading. + _ = ReconcileClusterManifestsAsync(cluster, ct); + + // Query K8s Backup CRs and populate detail.Backups directly so the UI always + // reflects live state. DB persistence is a separate best-effort step below. + + detail.Backups = await FetchBackupsFromK8sAsync(cluster, ct); } catch (Exception) { @@ -661,9 +1099,279 @@ public class CnpgService( detail.Phase = "Unable to reach cluster"; } + // Persist any newly discovered backups to the DB (for restore source selection etc.). + // This is best-effort — a failure here never prevents backups from being displayed, + // since detail.Backups is already populated from K8s above. + + await PersistK8sBackupsAsync(cluster.Id, detail.Backups, ct); + + // If K8s was unreachable, fall back to whatever is already in the DB. + + if (detail.Backups.Count == 0) + { + using ApplicationDbContext backupsDb = dbFactory.CreateDbContext(); + detail.Backups = await backupsDb.CnpgBackups + .Where(b => b.CnpgClusterId == cluster.Id) + .OrderByDescending(b => b.StartedAt) + .Take(20) + .ToListAsync(ct); + } + return detail; } + /// + /// Re-applies the CNPG Cluster and ScheduledBackup manifests to K8s so that + /// any code-level config changes (e.g. archive_timeout, target:primary) reach + /// existing clusters without requiring a manual upgrade. kubectl apply is + /// idempotent — when the spec is already in sync it issues no write. Errors + /// are swallowed because this is a best-effort background reconcile. + /// + private async Task ReconcileClusterManifestsAsync(CnpgCluster cluster, CancellationToken ct) + { + if (cluster.StorageLink is null || cluster.KubernetesCluster.Kubeconfig is null) + return; + + try + { + string kubeconfig = cluster.KubernetesCluster.Kubeconfig; + string s3SecretName = $"{cluster.Name}-s3-credentials"; + + await k8sFactory.ApplyManifestAsync( + BuildClusterManifest(cluster, cluster.StorageLink, s3SecretName), kubeconfig, ct); + + if (!string.IsNullOrWhiteSpace(cluster.BackupSchedule)) + { + await k8sFactory.ApplyManifestAsync( + BuildScheduledBackupManifest(cluster.Name, cluster.Namespace, cluster.BackupSchedule), + kubeconfig, ct); + } + } + catch { /* non-fatal */ } + } + + /// + /// Reconciles the persisted cluster status with the live Kubernetes state. + /// Transitions from Creating/Restoring to Running when the cluster becomes healthy, + /// and marks Failed if the cluster phase indicates an error condition. + /// + private async Task ReconcileClusterStatusAsync( + CnpgCluster cluster, CnpgClusterDetail detail, CancellationToken ct) + { + CnpgClusterStatus? newStatus = DetermineStatusFromPhase(detail.Phase, cluster.Status); + + if (newStatus is null || newStatus == cluster.Status) + { + return; + } + + try + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + CnpgCluster? tracked = await db.CnpgClusters.FindAsync([cluster.Id], ct); + + if (tracked is not null && tracked.Status != newStatus.Value) + { + tracked.Status = newStatus.Value; + await db.SaveChangesAsync(ct); + cluster.Status = newStatus.Value; + } + } + catch + { + // Non-fatal: status reconciliation failure should not prevent the detail view loading. + } + } + + /// + /// Maps the live CNPG cluster phase string to the appropriate managed status. + /// Returns null if no transition should occur. + /// + /// + /// Queries K8s for all Backup CRs in the cluster's namespace and returns them as + /// in-memory CnpgBackup objects (not yet persisted). This is the display-time source + /// of truth — the UI always shows what K8s has, regardless of DB state. + /// Returns an empty list if the query fails (K8s unreachable, CRD missing, etc.). + /// + private async Task> FetchBackupsFromK8sAsync( + CnpgCluster cluster, CancellationToken ct) + { + try + { + // Query all Backup CRs in the namespace without a label selector so we are + // not sensitive to label differences across CNPG versions or plugin configurations. + string backupsJson = await k8sFactory.GetJsonAsync( + "backups.postgresql.cnpg.io", + cluster.Namespace, + cluster.KubernetesCluster.Kubeconfig!, + ct: ct); + + using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(backupsJson); + System.Text.Json.JsonElement root = doc.RootElement; + + if (!root.TryGetProperty("items", out System.Text.Json.JsonElement items)) + return []; + + List result = []; + + foreach (System.Text.Json.JsonElement item in items.EnumerateArray()) + { + if (!item.TryGetProperty("metadata", out System.Text.Json.JsonElement meta)) + continue; + + string? name = meta.TryGetProperty("name", out System.Text.Json.JsonElement nameEl) + ? nameEl.GetString() : null; + if (string.IsNullOrEmpty(name)) + continue; + + // Filter to backups belonging to this cluster via spec.cluster.name. + if (!item.TryGetProperty("spec", out System.Text.Json.JsonElement spec) + || !spec.TryGetProperty("cluster", out System.Text.Json.JsonElement clusterEl) + || !clusterEl.TryGetProperty("name", out System.Text.Json.JsonElement clusterNameEl) + || clusterNameEl.GetString() != cluster.Name) + continue; + + string phase = ""; + string? barmanId = null; + DateTime? startedAt = null; + DateTime? stoppedAt = null; + + if (item.TryGetProperty("status", out System.Text.Json.JsonElement status)) + { + if (status.TryGetProperty("phase", out System.Text.Json.JsonElement phaseEl)) + phase = phaseEl.GetString() ?? ""; + + if (status.TryGetProperty("backupId", out System.Text.Json.JsonElement barmanIdEl)) + barmanId = barmanIdEl.GetString(); + + if (status.TryGetProperty("startedAt", out System.Text.Json.JsonElement startEl) + && DateTime.TryParse(startEl.GetString(), null, + System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsedStart)) + startedAt = parsedStart; + + if (status.TryGetProperty("stoppedAt", out System.Text.Json.JsonElement stopEl) + && DateTime.TryParse(stopEl.GetString(), null, + System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsedStop)) + stoppedAt = parsedStop; + } + + CnpgBackupStatus backupStatus = phase.ToLowerInvariant() switch + { + "completed" => CnpgBackupStatus.Completed, + "failed" => CnpgBackupStatus.Failed, + _ => CnpgBackupStatus.Running + }; + + // A backup not created by our BackupAsync is from the ScheduledBackup CR. + CnpgBackupType backupType = name.StartsWith($"{cluster.Name}-") + && name.Length > cluster.Name.Length + 1 + && !meta.TryGetProperty("ownerReferences", out _) + ? CnpgBackupType.OnDemand + : CnpgBackupType.Scheduled; + + result.Add(new CnpgBackup + { + Id = Guid.Empty, + CnpgClusterId = cluster.Id, + Name = name, + Type = backupType, + Status = backupStatus, + StartedAt = startedAt ?? DateTime.UtcNow, + CompletedAt = backupStatus != CnpgBackupStatus.Running ? stoppedAt : null, + BarmanId = barmanId + }); + } + + return [.. result.OrderByDescending(b => b.StartedAt).Take(20)]; + } + catch + { + return []; + } + } + + /// + /// Persists backups fetched from K8s into the CnpgBackup table. Inserts rows that + /// are not yet in the DB and updates the status of any rows that have changed. + /// Best-effort — exceptions are swallowed so a DB failure never breaks the detail view. + /// + private async Task PersistK8sBackupsAsync( + Guid cnpgClusterId, List k8sBackups, CancellationToken ct) + { + if (k8sBackups.Count == 0) + return; + + // Each backup is saved in its own context so a unique-constraint violation on one + // row (e.g. from a concurrent request) does not roll back saves for other rows. + foreach (CnpgBackup k8s in k8sBackups) + { + try + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgBackup? existing = await db.CnpgBackups + .FirstOrDefaultAsync( + b => b.CnpgClusterId == cnpgClusterId && b.Name == k8s.Name, ct); + + if (existing is null) + { + db.CnpgBackups.Add(new CnpgBackup + { + Id = Guid.NewGuid(), + CnpgClusterId = cnpgClusterId, + Name = k8s.Name, + Type = k8s.Type, + Status = k8s.Status, + StartedAt = k8s.StartedAt, + CompletedAt = k8s.CompletedAt + }); + await db.SaveChangesAsync(ct); + } + else if (existing.Status != k8s.Status) + { + existing.Status = k8s.Status; + if (k8s.Status != CnpgBackupStatus.Running) + existing.CompletedAt = k8s.CompletedAt ?? DateTime.UtcNow; + await db.SaveChangesAsync(ct); + } + } + catch + { + // DB persistence is optional — the backup list is already set from K8s. + } + } + } + + private static CnpgClusterStatus? DetermineStatusFromPhase(string phase, CnpgClusterStatus currentStatus) + { + string lower = phase.ToLowerInvariant(); + + // Healthy / running phases — cluster is ready. + + if (lower.Contains("healthy") || lower == "cluster in healthy state") + { + if (currentStatus is CnpgClusterStatus.Creating + or CnpgClusterStatus.Restoring + or CnpgClusterStatus.Upgrading + or CnpgClusterStatus.Failed) + { + return CnpgClusterStatus.Running; + } + } + + // Failure phases. + + if (lower.Contains("failed") || lower.Contains("error")) + { + if (currentStatus is not CnpgClusterStatus.Failed and not CnpgClusterStatus.Deleting) + { + return CnpgClusterStatus.Failed; + } + } + + return null; + } + // ──────── Private Helpers ──────── /// @@ -671,9 +1379,12 @@ public class CnpgService( /// in the target namespace so CNPG's Barman can access the backup bucket. /// private async Task EnsureStorageSecretsInK8sAsync( - Guid tenantId, StorageLink storageLink, string secretName, string ns, CancellationToken ct) + Guid tenantId, StorageLink storageLink, string secretName, string ns, + string kubeconfig, CancellationToken ct) { - // Tag the vault secrets for sync. The sync controller will create the K8s Secret. + // Retrieve the S3 credentials from the vault and physically create a + // Kubernetes Secret in the target namespace. The CNPG ObjectStore references + // this Secret to authenticate with the S3-compatible storage. List secrets = await vaultService.GetStorageLinkSecretsAsync(tenantId, storageLink.Id, ct); @@ -681,10 +1392,39 @@ public class CnpgService( { if (secret.Name == "ACCESS_KEY" || secret.Name == "SECRET_KEY") { - await vaultService.ConfigureKubernetesSyncAsync( - secret.Id, true, secretName, ns, ct); + await vaultService.ConfigureKubernetesSyncAsync(secret.Id, true, secretName, ns, ct); } } + + // Decrypt the actual credential values so we can create the K8s Secret immediately. + + string? accessKey = await vaultService.GetStorageLinkSecretValueAsync(tenantId, storageLink.Id, "ACCESS_KEY", ct); + string? secretKey = await vaultService.GetStorageLinkSecretValueAsync(tenantId, storageLink.Id, "SECRET_KEY", ct); + + if (accessKey is null || secretKey is null) + { + throw new InvalidOperationException( + "S3 credentials (ACCESS_KEY and SECRET_KEY) are missing from the storage link vault. " + + "Add them via the Storage tab before creating a backup-enabled cluster."); + } + + // Apply the Secret manifest directly so it exists immediately for the ObjectStore. + + string accessKeyB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(accessKey)); + string secretKeyB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(secretKey)); + + StringBuilder sb = new(); + sb.AppendLine("apiVersion: v1"); + sb.AppendLine("kind: Secret"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {secretName}"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("type: Opaque"); + sb.AppendLine("data:"); + sb.AppendLine($" ACCESS_KEY: {accessKeyB64}"); + sb.AppendLine($" SECRET_KEY: {secretKeyB64}"); + + await k8sFactory.ApplyManifestAsync(sb.ToString(), kubeconfig, ct); } /// @@ -772,6 +1512,11 @@ public class CnpgService( sb.AppendLine(" parameters:"); sb.AppendLine(" shared_buffers: \"256MB\""); sb.AppendLine(" max_connections: \"200\""); + // Force WAL segment rotation at least every 5 minutes on idle clusters. + // Without this, segments never fill (16 MB each) so WAL 9 is never archived + // and backup-based recovery always fails with "recovery ended before target". + if (storageLink is not null) + sb.AppendLine(" archive_timeout: \"5min\""); sb.AppendLine(" storage:"); sb.AppendLine($" size: {cnpg.StorageSize}"); @@ -795,7 +1540,8 @@ public class CnpgService( /// The ObjectStore references the cluster and defines S3 destination/credentials. /// private static string BuildObjectStoreManifest( - string clusterName, string ns, StorageLink storageLink, string s3SecretName) + string clusterName, string ns, StorageLink storageLink, string s3SecretName, + int retentionDays = 30) { StringBuilder sb = new(); @@ -817,8 +1563,7 @@ public class CnpgService( sb.AppendLine(" key: SECRET_KEY"); sb.AppendLine(" wal:"); sb.AppendLine(" compression: gzip"); - sb.AppendLine(" clusterRef:"); - sb.AppendLine($" name: {clusterName}"); + sb.AppendLine($" retentionPolicy: \"{retentionDays}d\""); return sb.ToString(); } @@ -838,10 +1583,14 @@ public class CnpgService( sb.AppendLine($" namespace: {ns}"); sb.AppendLine("spec:"); sb.AppendLine($" schedule: \"{schedule}\""); + sb.AppendLine(" immediate: true"); sb.AppendLine(" backupOwnerReference: self"); + sb.AppendLine(" target: primary"); sb.AppendLine(" method: plugin"); sb.AppendLine(" pluginConfiguration:"); sb.AppendLine(" name: barman-cloud.cloudnative-pg.io"); + sb.AppendLine(" parameters:"); + sb.AppendLine($" barmanObjectName: {clusterName}-object-store"); sb.AppendLine(" cluster:"); sb.AppendLine($" name: {clusterName}"); @@ -861,9 +1610,12 @@ public class CnpgService( sb.AppendLine($" name: {backupName}"); sb.AppendLine($" namespace: {ns}"); sb.AppendLine("spec:"); + sb.AppendLine(" target: primary"); sb.AppendLine(" method: plugin"); sb.AppendLine(" pluginConfiguration:"); sb.AppendLine(" name: barman-cloud.cloudnative-pg.io"); + sb.AppendLine(" parameters:"); + sb.AppendLine($" barmanObjectName: {clusterName}-object-store"); sb.AppendLine(" cluster:"); sb.AppendLine($" name: {clusterName}"); @@ -871,14 +1623,29 @@ public class CnpgService( } /// - /// Builds a CNPG Cluster manifest that bootstraps from a Barman Cloud Plugin - /// backup with point-in-time recovery to the specified target time. - /// Uses the plugin-based recovery via an external ObjectStore reference. + /// Builds a CNPG Cluster manifest that bootstraps from a Barman Cloud Plugin backup + /// with point-in-time recovery to the specified target time. + /// + /// When is provided the recovery references that specific + /// CNPG Backup CR directly — CNPG resolves the ObjectStore from the Backup and does not + /// need an externalClusters entry. This is the preferred path when a concrete backup + /// is selected in the UI. + /// + /// When is null the manifest falls back to externalClusters + /// with the Barman Cloud Plugin referencing 's ObjectStore. + /// overrides the external-cluster alias (required for + /// in-place restores where source.Name == restored.Name). /// private static string BuildRestoreManifest( - CnpgCluster restored, CnpgCluster source, DateTime targetTime, string s3SecretName) + CnpgCluster restored, CnpgCluster source, DateTime? targetTime, string s3SecretName, + string? sourceAlias = null, string? barmanBackupId = null) { - StorageLink storageLink = source.StorageLink!; + // Always use externalClusters — the barman-cloud plugin requires an explicit + // ObjectStore reference to authenticate against the source backup archive. + // The backup.name (Backup CR reference) approach does not propagate credentials + // to the recovery pod, causing "no credentials defined" at restore time. + string alias = sourceAlias ?? source.Name; + StringBuilder sb = new(); sb.AppendLine("apiVersion: postgresql.cnpg.io/v1"); @@ -889,6 +1656,9 @@ public class CnpgService( sb.AppendLine("spec:"); sb.AppendLine($" instances: {restored.Instances}"); sb.AppendLine($" imageName: ghcr.io/cloudnative-pg/postgresql:{restored.PostgresVersion}"); + sb.AppendLine(" postgresql:"); + sb.AppendLine(" parameters:"); + sb.AppendLine(" archive_timeout: \"5min\""); sb.AppendLine(" storage:"); sb.AppendLine($" size: {restored.StorageSize}"); sb.AppendLine(" plugins:"); @@ -897,15 +1667,31 @@ public class CnpgService( sb.AppendLine($" barmanObjectName: {restored.Name}-object-store"); sb.AppendLine(" bootstrap:"); sb.AppendLine(" recovery:"); - sb.AppendLine($" source: {source.Name}"); + sb.AppendLine($" source: {alias}"); sb.AppendLine(" recoveryTarget:"); - sb.AppendLine($" targetTime: \"{targetTime:yyyy-MM-ddTHH:mm:ssZ}\""); + if (barmanBackupId is not null) + { + // backupID tells barman which base backup to restore; omit targetImmediate so + // PostgreSQL recovers to the latest available WAL then promotes. targetImmediate + // requires the backup-end WAL record (which lives at end_lsn, start of the NEXT + // segment) to be in S3 — that segment may not be archived if the cluster is idle. + sb.AppendLine($" backupID: \"{barmanBackupId}\""); + } + else + { + DateTime resolvedTarget = (targetTime ?? DateTime.UtcNow).ToUniversalTime(); + sb.AppendLine($" targetTime: \"{resolvedTarget:yyyy-MM-ddTHH:mm:ssZ}\""); + } sb.AppendLine(" externalClusters:"); - sb.AppendLine($" - name: {source.Name}"); + sb.AppendLine($" - name: {alias}"); sb.AppendLine(" plugin:"); sb.AppendLine(" name: barman-cloud.cloudnative-pg.io"); sb.AppendLine(" parameters:"); sb.AppendLine($" barmanObjectName: {source.Name}-object-store"); + // serverName tells the plugin which sub-directory of destinationPath holds the + // source backups. Without it the plugin defaults to the NEW cluster's name and + // finds an empty catalog because the backups live under the source cluster's name. + sb.AppendLine($" serverName: {source.Name}"); return sb.ToString(); } @@ -984,7 +1770,8 @@ public class CnpgService( if (statusEl.TryGetProperty("startTime", out System.Text.Json.JsonElement startEl)) { - if (DateTime.TryParse(startEl.GetString(), out DateTime parsed)) + if (DateTime.TryParse(startEl.GetString(), null, + System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsed)) { startTime = parsed; } diff --git a/src/EntKube.Web/Services/ComponentCatalog.cs b/src/EntKube.Web/Services/ComponentCatalog.cs index 7b51375..6c77aca 100644 --- a/src/EntKube.Web/Services/ComponentCatalog.cs +++ b/src/EntKube.Web/Services/ComponentCatalog.cs @@ -60,14 +60,6 @@ public class CatalogEntry /// public IReadOnlyList RequiresOneOf { get; init; } = []; - /// - /// 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. - /// - public IReadOnlyList CompanionKeys { get; init; } = []; - /// /// 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 } ]; + /// + /// 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. + /// + public static Dictionary GetSubchartParents() + { + Dictionary 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; + } + /// /// Looks up a catalog entry by its key. Returns null if not found. /// @@ -859,11 +991,28 @@ public static class ComponentCatalog /// 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)); } /// @@ -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); } diff --git a/src/EntKube.Web/Services/ComponentLifecycleService.cs b/src/EntKube.Web/Services/ComponentLifecycleService.cs index 9c7ed99..7ac8a1c 100644 --- a/src/EntKube.Web/Services/ComponentLifecycleService.cs +++ b/src/EntKube.Web/Services/ComponentLifecycleService.cs @@ -41,6 +41,10 @@ public class HelmCommand public string? Version { get; set; } public bool HasValues { get; set; } public string? ValuesYaml { get; set; } + /// For kubectl-apply-url: the remote manifest URL passed directly to kubectl apply -f. + public string? ManifestUrl { get; set; } + /// When true, skips --wait so Helm returns immediately after applying values. + public bool NoWait { get; set; } } /// @@ -178,9 +182,9 @@ public class ComponentLifecycleService(IDbContextFactory 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 d /// /// 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. /// - public async Task> InstallCompanionsAsync( + public async Task InstallSubchartsAsync( Guid componentId, CancellationToken ct = default) { - List 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() + }; + } + + /// + /// 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. + /// + 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); + } + + /// + /// 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. + /// + public async Task 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() + }; } /// @@ -408,6 +560,19 @@ public class ComponentLifecycleService(IDbContextFactory d }; } + // ManifestUrl components use kubectl apply -f 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 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 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 literals = []; @@ -707,11 +887,21 @@ public class ComponentLifecycleService(IDbContextFactory 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 d } } + /// + /// Applies a remote manifest URL directly via kubectl apply -f <url>. + /// Used for components like Gateway API CRDs where the authoritative source + /// is a GitHub release manifest rather than a Helm chart. + /// + /// + /// 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. + /// + public async Task 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 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 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 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); + } + /// /// 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 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 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) diff --git a/src/EntKube.Web/Services/ComponentScanService.cs b/src/EntKube.Web/Services/ComponentScanService.cs index a558590..8753074 100644 --- a/src/EntKube.Web/Services/ComponentScanService.cs +++ b/src/EntKube.Web/Services/ComponentScanService.cs @@ -28,6 +28,13 @@ public class DiscoveredHelmRelease /// True if a ClusterComponent with this name already exists on the cluster. /// public bool AlreadyTracked { get; set; } + + /// + /// 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. + /// + public string? ParentComponentKey { get; set; } } /// @@ -109,12 +116,24 @@ public class ComponentScanService(IDbContextFactory dbFact // Get existing components on this cluster to mark already-tracked ones. List existingComponents = await vaultService.GetComponentsAsync(cluster.Id, ct); - HashSet 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 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 subchartParents = ComponentCatalog.GetSubchartParents(); + foreach (KeyValuePair entry in latestByRelease) { (V1Secret secret, int revision) = entry.Value; @@ -122,7 +141,15 @@ public class ComponentScanService(IDbContextFactory 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 dbFact int imported = 0; List 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 dbFact /// /// 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. /// 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 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 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); } } + /// + /// 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. + /// + 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 dbFact } } + /// + /// Checks whether the Gateway API CRDs (httproutes.gateway.networking.k8s.io) are + /// installed on the cluster. Returns false if unreachable or CRDs are absent. + /// + public async Task 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; + } + } + + /// + /// 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. + /// + public async Task 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)); diff --git a/src/EntKube.Web/Services/DatabaseService.cs b/src/EntKube.Web/Services/DatabaseService.cs index 0647397..14b8bdb 100644 --- a/src/EntKube.Web/Services/DatabaseService.cs +++ b/src/EntKube.Web/Services/DatabaseService.cs @@ -80,11 +80,15 @@ public class DatabaseService(IDbContextFactory 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 dbFactory) .ToListAsync(ct); } + /// + /// 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. + /// + public async Task> 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 ──────── /// @@ -271,9 +295,9 @@ public class DatabaseService(IDbContextFactory dbFactory) // ──────── MongoDB Discovery ──────── /// - /// 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. /// public async Task> GetMongoDbClustersAsync( Guid tenantId, CancellationToken ct = default) @@ -283,7 +307,10 @@ public class DatabaseService(IDbContextFactory dbFactory) List 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 results = []; @@ -351,32 +378,28 @@ public class DatabaseService(IDbContextFactory 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 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"; diff --git a/src/EntKube.Web/Services/ExternalRouteService.cs b/src/EntKube.Web/Services/ExternalRouteService.cs index 28f7f58..458d639 100644 --- a/src/EntKube.Web/Services/ExternalRouteService.cs +++ b/src/EntKube.Web/Services/ExternalRouteService.cs @@ -167,6 +167,19 @@ public class ExternalRouteService(IDbContextFactory dbFact return GenerateHttpRouteYaml(route); } + public async Task 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); + } + /// /// Generates HTTPRoute YAML from an in-memory route object. /// Useful for previewing before saving. @@ -175,26 +188,9 @@ public class ExternalRouteService(IDbContextFactory 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 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; + /// + /// 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). + /// + 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} + """; + } + + /// + /// Generates the complete manifest for a route: HTTPRoute plus a Certificate + /// resource (when using ClusterIssuer TLS). Apply this single YAML to the cluster. + /// + public static string GenerateFullManifestYaml(ExternalRoute route) + { + string httpRoute = GenerateHttpRouteYaml(route); + string certificate = GenerateCertificateYaml(route); + + return string.IsNullOrEmpty(certificate) + ? httpRoute + : $"{httpRoute}\n---\n{certificate}"; } /// @@ -270,43 +307,186 @@ public class ExternalRouteService(IDbContextFactory dbFact return yaml; } + /// + /// Generates a gateway.networking.k8s.io/v1/Gateway resource with one HTTPS + /// listener per unique hostname in , plus an HTTP-to-HTTPS + /// redirect listener, and explicit cert-manager Certificate resources placed in + /// so they can be referenced by the Gateway's + /// certificateRefs without cross-namespace ReferenceGrants. + /// + /// The returned YAML contains multiple documents separated by ---: + /// 1. The Gateway resource + /// 2. An HTTP→HTTPS redirect HTTPRoute + /// 3. One Certificate resource per ClusterIssuer-mode hostname + /// + public static string GenerateGatewayYaml( + string gatewayName, + string gatewayNamespace, + IEnumerable 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 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 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); + } + + /// + /// 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). + /// + 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; + } + + /// Derives the TLS secret name from a hostname (used in both the Gateway listener and Certificate). + public static string ToCertSecretName(string hostname) => ToListenerName(hostname) + "-tls"; + // ── Private helpers ── /// - /// 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. /// - private static string ResolveGatewayName(IEnumerable components) + public static (string Name, string Namespace) ResolveGateway(IEnumerable components) { - if (components.Any(c => string.Equals(c.Name, "traefik", StringComparison.OrdinalIgnoreCase))) + List 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"); } /// - /// 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. /// - private static string ResolveGatewayNamespace(IEnumerable components) + public static string ResolveIngressClass(IEnumerable 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 components) => + ResolveGateway(components).Name; + + private static string ResolveGatewayNamespace(IEnumerable components) => + ResolveGateway(components).Namespace; } diff --git a/src/EntKube.Web/Services/IKubernetesClientFactory.cs b/src/EntKube.Web/Services/IKubernetesClientFactory.cs index 90b9bcd..9227062 100644 --- a/src/EntKube.Web/Services/IKubernetesClientFactory.cs +++ b/src/EntKube.Web/Services/IKubernetesClientFactory.cs @@ -38,4 +38,19 @@ public interface IKubernetesClientFactory /// Uses kubectl exec to connect to the primary and run psql. /// Task ExecuteSqlAsync(string clusterName, string ns, string sql, string kubeconfig, CancellationToken ct = default); + + /// + /// 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. + /// + Task ExecuteMongoAsync(string clusterName, string ns, string script, string kubeconfig, + string? username = null, string? password = null, CancellationToken ct = default); + + /// + /// Reads a single key from a Kubernetes Secret and returns the decoded value. + /// Returns null if the secret or key does not exist. + /// + Task GetSecretValueAsync(string secretName, string key, string ns, string kubeconfig, + CancellationToken ct = default); } diff --git a/src/EntKube.Web/Services/KeycloakService.cs b/src/EntKube.Web/Services/KeycloakService.cs new file mode 100644 index 0000000..e609dc3 --- /dev/null +++ b/src/EntKube.Web/Services/KeycloakService.cs @@ -0,0 +1,1409 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Amazon.S3; +using Amazon.S3.Model; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +// ── DTOs ───────────────────────────────────────────────────────────────────── + +public class KeycloakUserInfo +{ + public required string Id { get; set; } + public string? Username { get; set; } + public string? Email { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public bool Enabled { get; set; } + public bool EmailVerified { get; set; } + /// Custom user attributes (e.g. org_id, department). Each key maps to one or more values. + public Dictionary> Attributes { get; set; } = []; +} + +public class KeycloakGroupInfo +{ + public required string Id { get; set; } + public required string Name { get; set; } + public string? Path { get; set; } +} + +public class KeycloakIdpInfo +{ + public required string Alias { get; set; } + public string? DisplayName { get; set; } + public required string ProviderId { get; set; } + public bool Enabled { get; set; } +} + +public class KeycloakOrganizationInfo +{ + public required string Id { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public bool Enabled { get; set; } +} + +public class KeycloakRealmDetails +{ + public required string Realm { get; set; } + public string? DisplayName { get; set; } + public bool Enabled { get; set; } + public string? LoginTheme { get; set; } + public string? AccountTheme { get; set; } + public List Themes { get; set; } = []; +} + +/// +/// A detected Keycloak component on a cluster, with its configuration status. +/// +public class DetectedKeycloak +{ + public required ClusterComponent Component { get; set; } + public KeycloakComponentConfig? Config { get; set; } + + /// External URL from the component's ExternalRoutes, if any. + public string? SuggestedAdminUrl { get; set; } + + public bool IsConfigured => Config is not null; + public bool HasDatabase => Config?.CnpgDatabaseId is not null; +} + +/// +/// Manages Keycloak instances detected from installed ClusterComponents. +/// +/// Detection: scans ClusterComponents with catalog key "keycloak" and status Installed. +/// Database: links to a managed CNPG database; stores KC_DB_URL/USERNAME/PASSWORD +/// as component vault secrets synced to a K8s Secret in the Keycloak namespace. +/// Admin password: stored as component vault secret "KEYCLOAK_ADMIN_PASSWORD", +/// synced to the same K8s Secret. +/// Admin API: token obtained on-demand from /realms/master/protocol/openid-connect/token. +/// +public class KeycloakService( + IDbContextFactory dbFactory, + VaultService vaultService, + IHttpClientFactory httpClientFactory) +{ + private readonly record struct TokenCacheKey(Guid ComponentId); + private readonly Dictionary _tokenCache = []; + + // ── Discovery ───────────────────────────────────────────────────────────── + + /// + /// Returns all Keycloak components installed across the tenant's clusters, + /// together with their configuration state and suggested admin URL. + /// + public async Task> GetDetectedKeycloaksAsync( + Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + List components = await db.ClusterComponents + .Include(c => c.Cluster) + .Include(c => c.ExternalRoutes) + .Where(c => c.Cluster.TenantId == tenantId + && (c.Status == ComponentStatus.Installed || c.Status == ComponentStatus.Failed) + && (c.Name == "keycloak" + || c.ReleaseName == "keycloak" + || c.HelmChartName == "keycloakx" + || c.HelmChartName == "keycloak")) + .OrderBy(c => c.Cluster.Name).ThenBy(c => c.Name) + .ToListAsync(ct); + + List configs = await db.KeycloakComponentConfigs + .Include(cfg => cfg.CnpgDatabase).ThenInclude(d => d!.CnpgCluster) + .Where(cfg => cfg.TenantId == tenantId) + .ToListAsync(ct); + + return components.Select(comp => + { + KeycloakComponentConfig? cfg = configs.FirstOrDefault(c => c.ClusterComponentId == comp.Id); + + string? suggestedUrl = comp.ExternalRoutes + .OrderBy(r => r.CreatedAt) + .Select(r => $"https://{r.Hostname}") + .FirstOrDefault(); + + return new DetectedKeycloak + { + Component = comp, + Config = cfg, + SuggestedAdminUrl = suggestedUrl ?? cfg?.AdminUrl + }; + }).ToList(); + } + + // ── Configuration ───────────────────────────────────────────────────────── + + /// + /// Configures a detected Keycloak component: links it to a CNPG database, + /// stores the admin password, and syncs DB credentials + admin password + /// to a K8s Secret in the Keycloak namespace so the Helm chart can use them. + /// + public async Task ConfigureAsync( + Guid tenantId, + Guid clusterComponentId, + Guid? cnpgDatabaseId, + string adminUsername, + string adminPassword, + string adminUrl, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .Include(c => c.Cluster) + .FirstOrDefaultAsync(c => c.Id == clusterComponentId && c.Cluster.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Component not found."); + + // Only load the CNPG database when one was explicitly selected. + // Imported Keycloak instances backed by non-CNPG Postgres pass null here. + CnpgDatabase? cnpgDb = null; + if (cnpgDatabaseId.HasValue) + { + cnpgDb = await db.CnpgDatabases + .Include(d => d.CnpgCluster) + .FirstOrDefaultAsync(d => d.Id == cnpgDatabaseId && d.CnpgCluster.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG database not found."); + } + + // Upsert the config record. + + KeycloakComponentConfig? config = await db.KeycloakComponentConfigs + .FirstOrDefaultAsync(c => c.ClusterComponentId == clusterComponentId, ct); + + if (config is null) + { + config = new KeycloakComponentConfig + { + Id = Guid.NewGuid(), + TenantId = tenantId, + ClusterComponentId = clusterComponentId + }; + db.KeycloakComponentConfigs.Add(config); + } + + config.CnpgDatabaseId = cnpgDatabaseId; + config.AdminUsername = adminUsername; + config.AdminUrl = adminUrl.TrimEnd('/'); + + string keycloakNs = component.Namespace ?? "keycloak"; + string credSecretName = $"{component.ReleaseName ?? component.Name}-credentials"; + + // Fix placeholder secret name if the release name differs from the default. + if (!string.IsNullOrWhiteSpace(component.HelmValues)) + { + string updatedYaml = component.HelmValues.Replace( + "name: keycloak-credentials", $"name: {credSecretName}"); + if (updatedYaml != component.HelmValues) + { + component.HelmValues = updatedYaml; + } + } + + // Write DB connection details (non-sensitive) directly into Helm values so the chart + // always knows where the database is, independent of the K8s secret sync. + // KC_DB_URL in the secret takes precedence, but if the secret is missing or incomplete + // (e.g. first install before Configure was run), the chart-native config is the fallback. + if (cnpgDb is not null) + { + string dbHost = $"{cnpgDb.CnpgCluster.Name}-rw.{cnpgDb.CnpgCluster.Namespace}.svc.cluster.local"; + component.HelmValues = YamlFormMerger.MergeFormValues( + component.HelmValues ?? "", + new Dictionary + { + ["database.hostname"] = dbHost, + ["database.port"] = "5432", + ["database.database"] = cnpgDb.Name, + ["database.username"] = cnpgDb.Owner + }); + } + + await db.SaveChangesAsync(ct); + + await vaultService.InitializeVaultAsync(tenantId, ct); + + // Admin username + password → vault + K8s sync. + // Keycloak 26+ uses KC_BOOTSTRAP_ADMIN_USERNAME / KC_BOOTSTRAP_ADMIN_PASSWORD. + // Both are stored in the same K8s Secret so extraEnvFrom picks up all keys. + + VaultSecret adminUserSecret = await vaultService.SetComponentSecretAsync( + tenantId, clusterComponentId, "KC_BOOTSTRAP_ADMIN_USERNAME", adminUsername, ct); + await vaultService.ConfigureKubernetesSyncAsync( + adminUserSecret.Id, true, credSecretName, keycloakNs, ct); + + VaultSecret adminPwSecret = await vaultService.SetComponentSecretAsync( + tenantId, clusterComponentId, "KC_BOOTSTRAP_ADMIN_PASSWORD", adminPassword, ct); + await vaultService.ConfigureKubernetesSyncAsync( + adminPwSecret.Id, true, credSecretName, keycloakNs, ct); + + // DB credentials are only synced when a managed CNPG database is linked. + // For imported instances backed by non-CNPG Postgres, the DB secrets already + // exist in the cluster and are not managed by EntKube. + + if (cnpgDb is not null) + { + string host = $"{cnpgDb.CnpgCluster.Name}-rw.{cnpgDb.CnpgCluster.Namespace}.svc.cluster.local"; + string jdbcUrl = $"jdbc:postgresql://{host}/{cnpgDb.Name}"; + + VaultSecret dbUrlSecret = await vaultService.SetComponentSecretAsync( + tenantId, clusterComponentId, "KC_DB_URL", jdbcUrl, ct); + await vaultService.ConfigureKubernetesSyncAsync( + dbUrlSecret.Id, true, credSecretName, keycloakNs, ct); + + VaultSecret dbUserSecret = await vaultService.SetComponentSecretAsync( + tenantId, clusterComponentId, "KC_DB_USERNAME", cnpgDb.Owner, ct); + await vaultService.ConfigureKubernetesSyncAsync( + dbUserSecret.Id, true, credSecretName, keycloakNs, ct); + + string? dbPassword = await vaultService.GetCnpgDatabasePasswordAsync(tenantId, cnpgDatabaseId!.Value, ct); + + if (dbPassword is not null) + { + VaultSecret dbPwSecret = await vaultService.SetComponentSecretAsync( + tenantId, clusterComponentId, "KC_DB_PASSWORD", dbPassword, ct); + await vaultService.ConfigureKubernetesSyncAsync( + dbPwSecret.Id, true, credSecretName, keycloakNs, ct); + } + } + + return config; + } + + /// + /// Called automatically before every install/upgrade. If a KeycloakComponentConfig with a CNPG + /// database exists for this component, refreshes database.hostname/port/database/username in + /// HelmValues and rewrites the vault DB secrets so the pre-install sync picks them up. + /// Safe no-op for non-Keycloak components or unconfigured instances. + /// + public async Task RefreshDatabaseHelmValuesIfConfiguredAsync( + Guid tenantId, Guid clusterComponentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakComponentConfig? config = await db.KeycloakComponentConfigs + .FirstOrDefaultAsync(c => c.ClusterComponentId == clusterComponentId && c.TenantId == tenantId, ct); + + if (config?.CnpgDatabaseId is null) + { + return; + } + + await WriteDatabaseHelmValuesAsync(tenantId, clusterComponentId, config.CnpgDatabaseId.Value, ct); + } + + /// + /// Writes CNPG database connection details (non-sensitive: hostname, port, database name, + /// username) into the component's HelmValues so the keycloakx chart always has a working + /// KC_DB_URL_HOST / KC_DB_URL_DATABASE / KC_DB_USERNAME even before vault secrets are synced. + /// Also writes KC_DB_URL + KC_DB_USERNAME + KC_DB_PASSWORD to vault for the K8s secret. + /// No admin credentials required — call this at registration time when a database is selected. + /// + public async Task WriteDatabaseHelmValuesAsync( + Guid tenantId, + Guid clusterComponentId, + Guid cnpgDatabaseId, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .Include(c => c.Cluster) + .FirstOrDefaultAsync(c => c.Id == clusterComponentId && c.Cluster.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Component not found."); + + CnpgDatabase cnpgDb = await db.CnpgDatabases + .Include(d => d.CnpgCluster) + .FirstOrDefaultAsync(d => d.Id == cnpgDatabaseId && d.CnpgCluster.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG database not found."); + + string keycloakNs = component.Namespace ?? "keycloak"; + string credSecretName = $"{component.ReleaseName ?? component.Name}-credentials"; + string host = $"{cnpgDb.CnpgCluster.Name}-rw.{cnpgDb.CnpgCluster.Namespace}.svc.cluster.local"; + + component.HelmValues = YamlFormMerger.MergeFormValues( + component.HelmValues ?? "", + new Dictionary + { + ["database.hostname"] = host, + ["database.port"] = "5432", + ["database.database"] = cnpgDb.Name, + ["database.username"] = cnpgDb.Owner + }); + + await db.SaveChangesAsync(ct); + + // Also write env-var secrets so the K8s secret is complete when synced. + await vaultService.InitializeVaultAsync(tenantId, ct); + + string jdbcUrl = $"jdbc:postgresql://{host}/{cnpgDb.Name}"; + + VaultSecret dbUrlSecret = await vaultService.SetComponentSecretAsync( + tenantId, clusterComponentId, "KC_DB_URL", jdbcUrl, ct); + await vaultService.ConfigureKubernetesSyncAsync( + dbUrlSecret.Id, true, credSecretName, keycloakNs, ct); + + VaultSecret dbUserSecret = await vaultService.SetComponentSecretAsync( + tenantId, clusterComponentId, "KC_DB_USERNAME", cnpgDb.Owner, ct); + await vaultService.ConfigureKubernetesSyncAsync( + dbUserSecret.Id, true, credSecretName, keycloakNs, ct); + + string? dbPassword = await vaultService.GetCnpgDatabasePasswordAsync(tenantId, cnpgDatabaseId, ct); + if (dbPassword is not null) + { + VaultSecret dbPwSecret = await vaultService.SetComponentSecretAsync( + tenantId, clusterComponentId, "KC_DB_PASSWORD", dbPassword, ct); + await vaultService.ConfigureKubernetesSyncAsync( + dbPwSecret.Id, true, credSecretName, keycloakNs, ct); + } + } + + /// + /// Updates the admin URL / credentials without touching the database config. + /// Pass null for newAdminPassword to keep the existing password. + /// + public async Task UpdateConfigAsync( + Guid tenantId, + Guid configId, + string adminUrl, + string adminUsername, + string? newAdminPassword, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakComponentConfig config = await db.KeycloakComponentConfigs + .FirstOrDefaultAsync(c => c.Id == configId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Config not found."); + + config.AdminUrl = adminUrl.TrimEnd('/'); + config.AdminUsername = adminUsername; + await db.SaveChangesAsync(ct); + + if (!string.IsNullOrWhiteSpace(newAdminPassword)) + { + await vaultService.SetComponentSecretAsync( + tenantId, config.ClusterComponentId, "KEYCLOAK_ADMIN_PASSWORD", newAdminPassword, ct); + } + + _tokenCache.Remove(new TokenCacheKey(config.ClusterComponentId)); + } + + /// + /// Removes a Keycloak component config and all its realm/backup records. + /// Does NOT modify anything in Keycloak itself. + /// + public async Task RemoveConfigAsync(Guid tenantId, Guid configId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakComponentConfig config = await db.KeycloakComponentConfigs + .FirstOrDefaultAsync(c => c.Id == configId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Config not found."); + + db.KeycloakComponentConfigs.Remove(config); + await db.SaveChangesAsync(ct); + _tokenCache.Remove(new TokenCacheKey(config.ClusterComponentId)); + } + + // ── Realms ──────────────────────────────────────────────────────────────── + + public async Task> GetRealmsForConfigAsync( + Guid tenantId, Guid configId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.KeycloakRealms + .Include(r => r.LinkedApp).ThenInclude(a => a!.Customer) + .Where(r => r.TenantId == tenantId && r.KeycloakComponentConfigId == configId) + .OrderBy(r => r.DisplayName) + .ToListAsync(ct); + } + + /// + /// Fetches all realms from the live Keycloak Admin API and imports any that don't + /// yet have a local record in EntKube. Skips the built-in master realm. + /// Safe to call repeatedly — existing records are never overwritten. + /// Returns the full up-to-date realm list. + /// + public async Task> SyncRealmsFromKeycloakAsync( + Guid tenantId, Guid configId, CancellationToken ct = default) + { + KeycloakComponentConfig config = await GetConfigAsync(tenantId, configId, ct); + string token = await GetAdminTokenAsync(tenantId, config, ct); + + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, + $"{config.AdminUrl}/admin/realms") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + resp.EnsureSuccessStatusCode(); + + using JsonDocument doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + HashSet existing = (await db.KeycloakRealms + .Where(r => r.KeycloakComponentConfigId == configId && r.TenantId == tenantId) + .Select(r => r.RealmName) + .ToListAsync(ct)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (JsonElement realmEl in doc.RootElement.EnumerateArray()) + { + string? realmName = realmEl.GetStringOrDefault("realm"); + if (string.IsNullOrWhiteSpace(realmName) || realmName == "master") continue; + if (existing.Contains(realmName)) continue; + + string raw = realmEl.GetStringOrDefault("displayName") ?? ""; + string displayName = string.IsNullOrWhiteSpace(raw) ? realmName : raw; + bool enabled = realmEl.TryGetProperty("enabled", out JsonElement en) && en.GetBoolean(); + + db.KeycloakRealms.Add(new KeycloakRealm + { + Id = Guid.NewGuid(), + KeycloakComponentConfigId = configId, + TenantId = tenantId, + RealmName = realmName, + DisplayName = displayName, + Enabled = enabled + }); + + existing.Add(realmName); + } + + await db.SaveChangesAsync(ct); + + return await db.KeycloakRealms + .Include(r => r.LinkedApp).ThenInclude(a => a!.Customer) + .Where(r => r.KeycloakComponentConfigId == configId && r.TenantId == tenantId) + .OrderBy(r => r.DisplayName) + .ToListAsync(ct); + } + + public async Task CreateRealmAsync( + Guid tenantId, + Guid configId, + string realmName, + string displayName, + string? loginTheme, + CancellationToken ct = default) + { + KeycloakComponentConfig config = await GetConfigAsync(tenantId, configId, ct); + string token = await GetAdminTokenAsync(tenantId, config, ct); + + HttpClient http = CreateHttpClient(); + + var body = new { realm = realmName, displayName, enabled = true, loginTheme = loginTheme ?? "" }; + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Post, + $"{config.AdminUrl}/admin/realms") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) }, + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }, ct); + + resp.EnsureSuccessStatusCode(); + + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakRealm realm = new() + { + Id = Guid.NewGuid(), + KeycloakComponentConfigId = configId, + TenantId = tenantId, + RealmName = realmName, + DisplayName = displayName, + LoginTheme = loginTheme, + Enabled = true + }; + + db.KeycloakRealms.Add(realm); + await db.SaveChangesAsync(ct); + return realm; + } + + public async Task DeleteRealmAsync(Guid tenantId, Guid realmId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakRealm realm = await db.KeycloakRealms + .Include(r => r.ComponentConfig) + .FirstOrDefaultAsync(r => r.Id == realmId && r.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Realm not found."); + + try + { + string token = await GetAdminTokenAsync(tenantId, realm.ComponentConfig, ct); + HttpClient http = CreateHttpClient(); + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Delete, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + if (!resp.IsSuccessStatusCode && resp.StatusCode != System.Net.HttpStatusCode.NotFound) + { + resp.EnsureSuccessStatusCode(); + } + } + catch (Exception) { /* best-effort Keycloak delete */ } + + db.KeycloakRealms.Remove(realm); + await db.SaveChangesAsync(ct); + } + + public async Task GetRealmDetailsAsync( + Guid tenantId, Guid realmId, CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + resp.EnsureSuccessStatusCode(); + + using JsonDocument doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + JsonElement root = doc.RootElement; + + List themes = []; + + try + { + HttpResponseMessage themeResp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, + $"{realm.ComponentConfig.AdminUrl}/admin/serverinfo") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + if (themeResp.IsSuccessStatusCode) + { + using JsonDocument tDoc = JsonDocument.Parse(await themeResp.Content.ReadAsStringAsync(ct)); + + if (tDoc.RootElement.TryGetProperty("themes", out JsonElement themesEl) + && themesEl.TryGetProperty("login", out JsonElement loginThemes)) + { + themes = loginThemes.EnumerateArray() + .Select(t => t.GetStringOrDefault("name") ?? "") + .Where(t => t.Length > 0) + .ToList(); + } + } + } + catch (Exception) { /* non-fatal */ } + + return new KeycloakRealmDetails + { + Realm = root.GetStringOrDefault("realm") ?? realm.RealmName, + DisplayName = root.GetStringOrDefault("displayName"), + Enabled = root.TryGetProperty("enabled", out JsonElement en) && en.GetBoolean(), + LoginTheme = root.GetStringOrDefault("loginTheme"), + AccountTheme = root.GetStringOrDefault("accountTheme"), + Themes = themes + }; + } + + public async Task UpdateRealmThemesAsync( + Guid tenantId, Guid realmId, string? loginTheme, string? accountTheme, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakRealm realm = await db.KeycloakRealms + .Include(r => r.ComponentConfig) + .FirstOrDefaultAsync(r => r.Id == realmId && r.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Realm not found."); + + string token = await GetAdminTokenAsync(tenantId, realm.ComponentConfig, ct); + HttpClient http = CreateHttpClient(); + + var patch = new { loginTheme, accountTheme }; + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Put, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) }, + Content = new StringContent(JsonSerializer.Serialize(patch), Encoding.UTF8, "application/json") + }, ct); + + resp.EnsureSuccessStatusCode(); + + realm.LoginTheme = loginTheme; + realm.AccountTheme = accountTheme; + await db.SaveChangesAsync(ct); + } + + // ── Users ───────────────────────────────────────────────────────────────── + + public async Task> GetUsersAsync( + Guid tenantId, Guid realmId, CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/users?max=100") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + resp.EnsureSuccessStatusCode(); + + using JsonDocument doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + + return doc.RootElement.EnumerateArray().Select(u => + { + Dictionary> attrs = []; + + if (u.TryGetProperty("attributes", out JsonElement attrsEl) + && attrsEl.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty prop in attrsEl.EnumerateObject()) + { + List values = prop.Value.ValueKind == JsonValueKind.Array + ? prop.Value.EnumerateArray() + .Select(v => v.GetString() ?? "") + .Where(v => v.Length > 0) + .ToList() + : []; + + if (values.Count > 0) + { + attrs[prop.Name] = values; + } + } + } + + return new KeycloakUserInfo + { + Id = u.GetStringOrDefault("id") ?? "", + Username = u.GetStringOrDefault("username"), + Email = u.GetStringOrDefault("email"), + FirstName = u.GetStringOrDefault("firstName"), + LastName = u.GetStringOrDefault("lastName"), + Enabled = u.TryGetProperty("enabled", out JsonElement en) && en.GetBoolean(), + EmailVerified = u.TryGetProperty("emailVerified", out JsonElement ev) && ev.GetBoolean(), + Attributes = attrs + }; + }).ToList(); + } + + public async Task CreateUserAsync( + Guid tenantId, Guid realmId, + string username, string email, string firstName, string lastName, + string? password, CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + object body = password is not null + ? new + { + username, email, firstName, lastName, enabled = true, emailVerified = true, + credentials = new[] { new { type = "password", value = password, temporary = false } } + } + : (object)new { username, email, firstName, lastName, enabled = true, emailVerified = true }; + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Post, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/users") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) }, + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }, ct); + + resp.EnsureSuccessStatusCode(); + } + + public async Task DeleteUserAsync( + Guid tenantId, Guid realmId, string userId, CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Delete, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/users/{userId}") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + resp.EnsureSuccessStatusCode(); + } + + // ── Identity Providers ──────────────────────────────────────────────────── + + public async Task> GetIdpsAsync( + Guid tenantId, Guid realmId, CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/identity-provider/instances") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + resp.EnsureSuccessStatusCode(); + + using JsonDocument doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + + return doc.RootElement.EnumerateArray().Select(idp => new KeycloakIdpInfo + { + Alias = idp.GetStringOrDefault("alias") ?? "", + DisplayName = idp.GetStringOrDefault("displayName"), + ProviderId = idp.GetStringOrDefault("providerId") ?? "", + Enabled = idp.TryGetProperty("enabled", out JsonElement en) && en.GetBoolean() + }).ToList(); + } + + public async Task CreateIdpAsync( + Guid tenantId, Guid realmId, + string alias, string providerId, string clientId, string clientSecret, + CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + var body = new + { + alias, + providerId, + enabled = true, + config = new { clientId, clientSecret, useJwksUrl = "true" } + }; + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Post, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/identity-provider/instances") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) }, + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }, ct); + + resp.EnsureSuccessStatusCode(); + } + + public async Task DeleteIdpAsync( + Guid tenantId, Guid realmId, string alias, CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Delete, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/identity-provider/instances/{alias}") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + resp.EnsureSuccessStatusCode(); + } + + // ── Groups ──────────────────────────────────────────────────────────────── + + public async Task> GetGroupsAsync( + Guid tenantId, Guid realmId, CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/groups") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + resp.EnsureSuccessStatusCode(); + + using JsonDocument doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + + return doc.RootElement.EnumerateArray().Select(g => new KeycloakGroupInfo + { + Id = g.GetStringOrDefault("id") ?? "", + Name = g.GetStringOrDefault("name") ?? "", + Path = g.GetStringOrDefault("path") + }).ToList(); + } + + public async Task CreateGroupAsync( + Guid tenantId, Guid realmId, string name, CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Post, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/groups") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) }, + Content = new StringContent(JsonSerializer.Serialize(new { name }), Encoding.UTF8, "application/json") + }, ct); + + resp.EnsureSuccessStatusCode(); + } + + public async Task DeleteGroupAsync( + Guid tenantId, Guid realmId, string groupId, CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Delete, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/groups/{groupId}") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + resp.EnsureSuccessStatusCode(); + } + + // ── Organizations (Keycloak 26+) ────────────────────────────────────────── + + public async Task> GetOrganizationsAsync( + Guid tenantId, Guid realmId, CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/organizations") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return []; + } + + resp.EnsureSuccessStatusCode(); + + using JsonDocument doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + + return doc.RootElement.EnumerateArray().Select(o => new KeycloakOrganizationInfo + { + Id = o.GetStringOrDefault("id") ?? "", + Name = o.GetStringOrDefault("name") ?? "", + Description = o.GetStringOrDefault("description"), + Enabled = o.TryGetProperty("enabled", out JsonElement en) && en.GetBoolean() + }).ToList(); + } + + public async Task CreateOrganizationAsync( + Guid tenantId, Guid realmId, string name, string? description, + CancellationToken ct = default) + { + (KeycloakRealm realm, string token) = await LoadRealmAndTokenAsync(tenantId, realmId, ct); + + HttpClient http = CreateHttpClient(); + + var body = new { name, description = description ?? "", enabled = true }; + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Post, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/organizations") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) }, + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }, ct); + + resp.EnsureSuccessStatusCode(); + } + + // ── Backup & Restore ────────────────────────────────────────────────────── + + public async Task> GetBackupsAsync( + Guid tenantId, Guid realmId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.KeycloakBackups + .Include(b => b.StorageLink) + .Where(b => b.TenantId == tenantId && b.KeycloakRealmId == realmId) + .OrderByDescending(b => b.CreatedAt) + .ToListAsync(ct); + } + + public async Task CreateBackupAsync( + Guid tenantId, Guid realmId, Guid storageLinkId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakRealm realm = await db.KeycloakRealms + .Include(r => r.ComponentConfig) + .FirstOrDefaultAsync(r => r.Id == realmId && r.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Realm not found."); + + StorageLink storageLink = await db.StorageLinks + .FirstOrDefaultAsync(s => s.Id == storageLinkId && s.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Storage link not found."); + + KeycloakBackup backup = new() + { + Id = Guid.NewGuid(), + KeycloakRealmId = realmId, + TenantId = tenantId, + StorageLinkId = storageLinkId, + ObjectKey = $"keycloak/{realm.RealmName}/{DateTime.UtcNow:yyyyMMdd-HHmmss}.json", + RealmName = realm.RealmName, + Status = KeycloakBackupStatus.Creating + }; + + db.KeycloakBackups.Add(backup); + await db.SaveChangesAsync(ct); + + try + { + string token = await GetAdminTokenAsync(tenantId, realm.ComponentConfig, ct); + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Post, + $"{realm.ComponentConfig.AdminUrl}/admin/realms/{realm.RealmName}/partial-export" + + "?exportClients=true&exportGroupsAndRoles=true") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + resp.EnsureSuccessStatusCode(); + + byte[] json = await resp.Content.ReadAsByteArrayAsync(ct); + + await UploadToS3Async(tenantId, storageLink, backup.ObjectKey, json, ct); + + backup.SizeBytes = json.Length; + backup.Status = KeycloakBackupStatus.Ready; + backup.CompletedAt = DateTime.UtcNow; + } + catch (Exception ex) + { + backup.Status = KeycloakBackupStatus.Failed; + backup.LastError = ex.Message; + } + + using ApplicationDbContext db2 = dbFactory.CreateDbContext(); + db2.KeycloakBackups.Update(backup); + await db2.SaveChangesAsync(ct); + + return backup; + } + + public async Task RestoreBackupAsync( + Guid tenantId, Guid backupId, string? targetRealmName, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakBackup backup = await db.KeycloakBackups + .Include(b => b.Realm).ThenInclude(r => r.ComponentConfig) + .Include(b => b.StorageLink) + .FirstOrDefaultAsync(b => b.Id == backupId && b.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Backup not found."); + + byte[] json = await DownloadFromS3Async(tenantId, backup.StorageLink, backup.ObjectKey, ct); + + if (!string.IsNullOrWhiteSpace(targetRealmName) && targetRealmName != backup.RealmName) + { + json = RenameRealmInJson(json, targetRealmName); + } + + string token = await GetAdminTokenAsync(tenantId, backup.Realm.ComponentConfig, ct); + HttpClient http = CreateHttpClient(); + + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Post, + $"{backup.Realm.ComponentConfig.AdminUrl}/admin/realms") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) }, + Content = new ByteArrayContent(json) + { + Headers = { ContentType = new MediaTypeHeaderValue("application/json") } + } + }, ct); + + resp.EnsureSuccessStatusCode(); + } + + public async Task DeleteBackupAsync(Guid tenantId, Guid backupId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakBackup backup = await db.KeycloakBackups + .Include(b => b.StorageLink) + .FirstOrDefaultAsync(b => b.Id == backupId && b.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Backup not found."); + + try + { + await DeleteFromS3Async(tenantId, backup.StorageLink, backup.ObjectKey, ct); + } + catch (Exception) { /* best effort */ } + + db.KeycloakBackups.Remove(backup); + await db.SaveChangesAsync(ct); + } + + // ── Portal linking ──────────────────────────────────────────────────────── + + public async Task LinkRealmToAppAsync( + Guid tenantId, Guid realmId, Guid appId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakRealm realm = await db.KeycloakRealms + .FirstOrDefaultAsync(r => r.Id == realmId && r.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Realm not found."); + + realm.LinkedAppId = appId; + await db.SaveChangesAsync(ct); + } + + public async Task UnlinkRealmFromAppAsync( + Guid tenantId, Guid realmId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakRealm realm = await db.KeycloakRealms + .FirstOrDefaultAsync(r => r.Id == realmId && r.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Realm not found."); + + realm.LinkedAppId = null; + await db.SaveChangesAsync(ct); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + public async Task> GetAppsAsync(Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Apps + .Include(a => a.Customer) + .Where(a => a.Customer.TenantId == tenantId) + .OrderBy(a => a.Customer.Name).ThenBy(a => a.Name) + .ToListAsync(ct); + } + + public async Task> GetDatabasesForComponentAsync( + Guid tenantId, Guid clusterComponentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent comp = await db.ClusterComponents + .FirstOrDefaultAsync(c => c.Id == clusterComponentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + return await db.CnpgDatabases + .Include(d => d.CnpgCluster) + .Where(d => d.CnpgCluster.TenantId == tenantId + && d.CnpgCluster.KubernetesClusterId == comp.ClusterId + && d.Status == CnpgDatabaseStatus.Ready) + .OrderBy(d => d.CnpgCluster.Name).ThenBy(d => d.Name) + .ToListAsync(ct); + } + + /// + /// Returns CNPG databases available on the given Kubernetes cluster. + /// Used to populate the database selector when registering a Keycloak component. + /// + public async Task> GetDatabasesForClusterAsync( + Guid tenantId, Guid kubernetesClusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.CnpgDatabases + .Include(d => d.CnpgCluster) + .Where(d => d.CnpgCluster.TenantId == tenantId + && d.CnpgCluster.KubernetesClusterId == kubernetesClusterId + && d.Status == CnpgDatabaseStatus.Ready) + .OrderBy(d => d.CnpgCluster.Name).ThenBy(d => d.Name) + .ToListAsync(ct); + } + + /// + /// Returns the KeycloakComponentConfig for a specific installed component, if any. + /// + public async Task GetConfigForComponentAsync( + Guid tenantId, Guid clusterComponentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.KeycloakComponentConfigs + .FirstOrDefaultAsync(c => c.ClusterComponentId == clusterComponentId + && c.TenantId == tenantId, ct); + } + + public async Task> GetConfigsForClusterAsync( + Guid tenantId, Guid kubernetesClusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.KeycloakComponentConfigs + .Include(c => c.CnpgDatabase) + .Where(c => c.TenantId == tenantId + && db.ClusterComponents + .Any(comp => comp.Id == c.ClusterComponentId && comp.ClusterId == kubernetesClusterId)) + .ToListAsync(ct); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private HttpClient CreateHttpClient() + { + HttpClient http = httpClientFactory.CreateClient(); + http.Timeout = TimeSpan.FromSeconds(15); + return http; + } + + private async Task GetConfigAsync( + Guid tenantId, Guid configId, CancellationToken ct) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.KeycloakComponentConfigs + .FirstOrDefaultAsync(c => c.Id == configId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Keycloak config not found."); + } + + private async Task<(KeycloakRealm Realm, string Token)> LoadRealmAndTokenAsync( + Guid tenantId, Guid realmId, CancellationToken ct) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KeycloakRealm realm = await db.KeycloakRealms + .Include(r => r.ComponentConfig) + .FirstOrDefaultAsync(r => r.Id == realmId && r.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Realm not found."); + + string token = await GetAdminTokenAsync(tenantId, realm.ComponentConfig, ct); + return (realm, token); + } + + private async Task GetAdminTokenAsync( + Guid tenantId, KeycloakComponentConfig config, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(config.AdminUrl)) + { + throw new InvalidOperationException( + "Keycloak admin URL is not configured. Set it in the Identity tab."); + } + + TokenCacheKey key = new(config.ClusterComponentId); + + if (_tokenCache.TryGetValue(key, out (string Token, DateTime Expiry) cached) + && cached.Expiry > DateTime.UtcNow.AddSeconds(30)) + { + return cached.Token; + } + + // Try the Keycloak 26+ name first, fall back to the legacy name for instances + // configured before the rename (imported clusters, pre-26 setups). + string? password = + await vaultService.GetComponentSecretValueAsync( + tenantId, config.ClusterComponentId, "KC_BOOTSTRAP_ADMIN_PASSWORD", ct) + ?? await vaultService.GetComponentSecretValueAsync( + tenantId, config.ClusterComponentId, "KEYCLOAK_ADMIN_PASSWORD", ct); + + if (string.IsNullOrWhiteSpace(password)) + { + throw new InvalidOperationException( + "Keycloak admin password not found in vault. Re-configure the Keycloak component."); + } + + HttpClient http = CreateHttpClient(); + + FormUrlEncodedContent form = new( + [ + new KeyValuePair("client_id", "admin-cli"), + new KeyValuePair("username", config.AdminUsername), + new KeyValuePair("password", password), + new KeyValuePair("grant_type", "password") + ]); + + HttpResponseMessage resp = await http.PostAsync( + $"{config.AdminUrl}/realms/master/protocol/openid-connect/token", form, ct); + + resp.EnsureSuccessStatusCode(); + + using JsonDocument doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + string token = doc.RootElement.GetProperty("access_token").GetString()!; + int expiresIn = doc.RootElement.TryGetProperty("expires_in", out JsonElement ei) + ? ei.GetInt32() : 300; + + _tokenCache[key] = (token, DateTime.UtcNow.AddSeconds(expiresIn)); + return token; + } + + // ── Themes ─────────────────────────────────────────────────────────────── + + /// + /// Returns available theme names per type (login, account, admin, email, welcome) + /// from the Keycloak server info API. Returns empty dict on failure. + /// + public async Task>> GetAvailableThemesAsync( + Guid tenantId, Guid configId, CancellationToken ct = default) + { + KeycloakComponentConfig config = await GetConfigAsync(tenantId, configId, ct); + string token = await GetAdminTokenAsync(tenantId, config, ct); + + HttpClient http = CreateHttpClient(); + HttpResponseMessage resp = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, + $"{config.AdminUrl}/admin/serverinfo") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", token) } + }, ct); + + if (!resp.IsSuccessStatusCode) + { + return []; + } + + using JsonDocument doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + + if (!doc.RootElement.TryGetProperty("themes", out JsonElement themesEl)) + { + return []; + } + + Dictionary> result = new(StringComparer.OrdinalIgnoreCase); + + foreach (JsonProperty typeProp in themesEl.EnumerateObject()) + { + List names = []; + + foreach (JsonElement themeEl in typeProp.Value.EnumerateArray()) + { + string? name = themeEl.TryGetProperty("name", out JsonElement n) ? n.GetString() : null; + if (!string.IsNullOrWhiteSpace(name)) + { + names.Add(name); + } + } + + result[typeProp.Name] = names; + } + + return result; + } + + /// Returns stored custom CSS for the given theme type, or null if none saved. + public async Task GetThemeCssAsync( + Guid tenantId, Guid clusterComponentId, string themeType, CancellationToken ct = default) + { + return await vaultService.GetComponentSecretValueAsync( + tenantId, clusterComponentId, $"theme-{themeType}-css", ct); + } + + /// Persists custom CSS for the given theme type in the vault (not synced to K8s). + public async Task SaveThemeCssAsync( + Guid tenantId, Guid clusterComponentId, string themeType, string css, CancellationToken ct = default) + { + await vaultService.InitializeVaultAsync(tenantId, ct); + await vaultService.SetComponentSecretAsync( + tenantId, clusterComponentId, $"theme-{themeType}-css", css, ct); + } + + // ── S3 helpers ──────────────────────────────────────────────────────────── + + private async Task UploadToS3Async( + Guid tenantId, StorageLink link, string key, byte[] data, CancellationToken ct) + { + using AmazonS3Client s3 = await CreateS3ClientAsync(tenantId, link, ct); + + await s3.PutObjectAsync(new PutObjectRequest + { + BucketName = link.BucketName, + Key = key, + InputStream = new MemoryStream(data), + ContentType = "application/json" + }, ct); + } + + private async Task DownloadFromS3Async( + Guid tenantId, StorageLink link, string key, CancellationToken ct) + { + using AmazonS3Client s3 = await CreateS3ClientAsync(tenantId, link, ct); + + GetObjectResponse obj = await s3.GetObjectAsync(new GetObjectRequest + { + BucketName = link.BucketName, + Key = key + }, ct); + + using MemoryStream ms = new(); + await obj.ResponseStream.CopyToAsync(ms, ct); + return ms.ToArray(); + } + + private async Task DeleteFromS3Async( + Guid tenantId, StorageLink link, string key, CancellationToken ct) + { + using AmazonS3Client s3 = await CreateS3ClientAsync(tenantId, link, ct); + + await s3.DeleteObjectAsync(new DeleteObjectRequest + { + BucketName = link.BucketName, + Key = key + }, ct); + } + + private async Task CreateS3ClientAsync( + Guid tenantId, StorageLink link, CancellationToken ct) + { + string? accessKey = await vaultService.GetStorageLinkSecretValueAsync( + tenantId, link.Id, "ACCESS_KEY", ct); + string? secretKey = await vaultService.GetStorageLinkSecretValueAsync( + tenantId, link.Id, "SECRET_KEY", ct); + + return new AmazonS3Client(accessKey, secretKey, new AmazonS3Config + { + ServiceURL = link.Endpoint, + ForcePathStyle = true + }); + } + + private static byte[] RenameRealmInJson(byte[] json, string newRealmName) + { + using JsonDocument doc = JsonDocument.Parse(json); + using MemoryStream ms = new(); + using Utf8JsonWriter writer = new(ms); + + writer.WriteStartObject(); + + foreach (JsonProperty prop in doc.RootElement.EnumerateObject()) + { + if (prop.Name == "realm") + writer.WriteString("realm", newRealmName); + else + prop.WriteTo(writer); + } + + writer.WriteEndObject(); + writer.Flush(); + return ms.ToArray(); + } +} + +internal static class JsonElementExtensions +{ + internal static string? GetStringOrDefault(this JsonElement el, string property) + { + return el.TryGetProperty(property, out JsonElement val) && val.ValueKind == JsonValueKind.String + ? val.GetString() + : null; + } +} diff --git a/src/EntKube.Web/Services/KubernetesClientFactory.cs b/src/EntKube.Web/Services/KubernetesClientFactory.cs index df220eb..1e5be1c 100644 --- a/src/EntKube.Web/Services/KubernetesClientFactory.cs +++ b/src/EntKube.Web/Services/KubernetesClientFactory.cs @@ -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 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 RunKubectlAsync(string arguments, CancellationToken ct) { using Process process = new() @@ -151,4 +205,51 @@ public class KubernetesClientFactory : IKubernetesClientFactory return output.ToString(); } + + /// + /// 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. + /// + private static async Task 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 outputTask = process.StandardOutput.ReadToEndAsync(ct); + Task 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; + } } diff --git a/src/EntKube.Web/Services/MongoService.cs b/src/EntKube.Web/Services/MongoService.cs new file mode 100644 index 0000000..28d029e --- /dev/null +++ b/src/EntKube.Web/Services/MongoService.cs @@ -0,0 +1,1889 @@ +using System.Security.Cryptography; +using System.Text; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// Manages the full lifecycle of MongoDB Community Operator clusters — from creation +/// through backup, restore, upgrade, database management, and deletion. Each operation +/// translates high-level intent into Kubernetes CRD manifests or Jobs applied to the +/// cluster where the MongoDB Community Operator is running. +/// +/// The service owns the MongoCluster, MongoDatabase, and MongoBackup records in the +/// database. It coordinates with VaultService to store database credentials and tag +/// them for Kubernetes sync so applications can consume them as K8s Secrets. +/// +/// Backups are implemented as Kubernetes Jobs: an init container runs mongodump and +/// writes to an emptyDir volume, then the main container uploads the archive to S3 +/// using the aws-cli image. Restores reverse this process. No PITR — recovery is to +/// the point in time of the selected backup dump. +/// +public class MongoService( + IDbContextFactory dbFactory, + VaultService vaultService, + IKubernetesClientFactory k8sFactory) +{ + // ──────── Cluster Lifecycle ──────── + + /// + /// Creates a new managed MongoDB cluster via the Community Operator. Validates that + /// the operator is installed on the target cluster, then generates the MongoDBCommunity + /// CRD manifest and applies it. If a storage link is provided, syncs S3 credentials + /// to a K8s Secret so backup Jobs can access the bucket. + /// + public async Task CreateClusterAsync( + Guid tenantId, + Guid kubernetesClusterId, + string name, + string ns, + int members, + string storageSize, + Guid? storageLinkId, + string? backupSchedule, + int retentionDays = 30, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Verify the MongoDB Community Operator is installed on the target cluster. + + bool operatorInstalled = await db.ClusterComponents + .AnyAsync(c => c.ClusterId == kubernetesClusterId + && (c.Name == "mongodb-community-operator" || c.Name == "mongodb-operator" + || c.ReleaseName == "mongodb-community-operator" || c.ReleaseName == "mongodb-operator") + && c.Status == ComponentStatus.Installed, ct); + + if (!operatorInstalled) + { + throw new InvalidOperationException( + "MongoDB Community Operator is not installed on this cluster. Install it first from the Components tab."); + } + + // Load the cluster's kubeconfig for applying the manifest. + + KubernetesCluster k8sCluster = await db.KubernetesClusters + .FirstAsync(k => k.Id == kubernetesClusterId, ct); + + // Load backup storage details if configured. + + StorageLink? storageLink = null; + + if (storageLinkId.HasValue) + { + storageLink = await db.StorageLinks + .FirstOrDefaultAsync(s => s.Id == storageLinkId.Value && s.TenantId == tenantId, ct); + } + + // Create the database record. + + MongoCluster mongoCluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + KubernetesClusterId = kubernetesClusterId, + Name = name, + Namespace = ns, + MongoVersion = "8.0.4", + Members = members, + StorageSize = storageSize, + StorageLinkId = storageLinkId, + BackupSchedule = backupSchedule, + RetentionDays = retentionDays, + Status = MongoClusterStatus.Creating + }; + + db.MongoClusters.Add(mongoCluster); + await db.SaveChangesAsync(ct); + + // If backup storage is configured, ensure the S3 credentials are synced + // to Kubernetes so backup Jobs can access the bucket. + + string? s3SecretName = null; + + if (storageLink is not null) + { + s3SecretName = $"{name}-s3-credentials"; + } + + // Generate an admin password. Store it in the vault (encrypted, linked to this + // cluster, with K8s sync enabled) and also apply the K8s Secret immediately so + // the Community Operator can find it before it processes the MongoDBCommunity CRD. + + string adminPassword = GeneratePassword(); + string adminK8sSecretName = $"{name}-admin-password"; + + await vaultService.InitializeVaultAsync(tenantId, ct); + await vaultService.SetMongoClusterSecretAsync( + tenantId, mongoCluster.Id, "ADMIN_PASSWORD", adminPassword, + adminK8sSecretName, ns, ct); + + string adminSecretManifest = BuildAdminSecretManifest(name, ns, adminPassword); + string clusterManifest = BuildClusterManifest(mongoCluster, storageLink, s3SecretName); + + await k8sFactory.EnsureNamespaceAsync(ns, k8sCluster.Kubeconfig!, ct); + + // The Community Operator sets serviceAccountName: mongodb-database on every pod it creates. + // It only creates this ServiceAccount in its own installation namespace, so when the MongoDB + // resource lives in a different namespace the pod admission fails with "serviceaccount not found". + // We apply the ServiceAccount ourselves so it always exists in the target namespace. + await k8sFactory.ApplyManifestAsync(BuildServiceAccountManifest(ns), k8sCluster.Kubeconfig!, ct); + + if (storageLink is not null && s3SecretName is not null) + { + await EnsureStorageSecretsInK8sAsync( + tenantId, storageLink, s3SecretName, ns, k8sCluster.Kubeconfig!, ct); + } + + await k8sFactory.ApplyManifestAsync(adminSecretManifest, k8sCluster.Kubeconfig!, ct); + await k8sFactory.ApplyManifestAsync(clusterManifest, k8sCluster.Kubeconfig!, ct); + + if (!string.IsNullOrEmpty(backupSchedule) && storageLink is not null && s3SecretName is not null) + { + string cronManifest = BuildScheduledBackupCronJobManifest(mongoCluster, storageLink, s3SecretName); + await k8sFactory.ApplyManifestAsync(cronManifest, k8sCluster.Kubeconfig!, ct); + } + + return mongoCluster; + } + + /// + /// Deletes a managed MongoDB cluster from Kubernetes and removes all associated + /// records (databases, backups, vault secrets). + /// + public async Task DeleteClusterAsync(Guid tenantId, Guid mongoClusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + MongoCluster mongo = await db.MongoClusters + .Include(c => c.KubernetesCluster) + .Include(c => c.Databases) + .FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("MongoDB cluster not found."); + + // Attempt to delete the K8s resources. If the cluster never actually deployed + // or the K8s API is unreachable, we still proceed with removing the DB record. + + try + { + await k8sFactory.DeleteManifestAsync( + "mongodbcommunity.mongodbcommunity.mongodb.com", mongo.Name, mongo.Namespace, + mongo.KubernetesCluster.Kubeconfig!, ct); + await k8sFactory.DeleteManifestAsync( + "secret", $"{mongo.Name}-admin-password", mongo.Namespace, + mongo.KubernetesCluster.Kubeconfig!, ct); + if (!string.IsNullOrEmpty(mongo.BackupSchedule)) + { + await k8sFactory.DeleteManifestAsync( + "cronjob", $"{mongo.Name}-scheduled-backup", mongo.Namespace, + mongo.KubernetesCluster.Kubeconfig!, ct); + } + } + catch (Exception) + { + // K8s operations failed — proceed with local cleanup regardless. + } + + // Remove vault secrets for all databases in this cluster. + + foreach (MongoDatabase database in mongo.Databases) + { + await DeleteDatabaseSecretsAsync(database.Id, ct); + } + + // Remove cluster-level vault secrets (admin password, etc.). + + await DeleteClusterSecretsAsync(mongo.Id, ct); + + // Remove the cluster record (cascades to databases and backups). + + db.MongoClusters.Remove(mongo); + await db.SaveChangesAsync(ct); + } + + /// + /// Upgrades a MongoDB cluster to a new version. Updates spec.version in the CRD — + /// the Community Operator performs a rolling upgrade of replica set members. + /// + public async Task UpgradeClusterAsync( + Guid tenantId, Guid mongoClusterId, string targetVersion, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + MongoCluster mongo = await db.MongoClusters + .Include(c => c.KubernetesCluster) + .Include(c => c.StorageLink) + .FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("MongoDB cluster not found."); + + mongo.MongoVersion = targetVersion; + mongo.Status = MongoClusterStatus.Upgrading; + await db.SaveChangesAsync(ct); + + string? s3SecretName = mongo.StorageLinkId.HasValue ? $"{mongo.Name}-s3-credentials" : null; + string manifest = BuildClusterManifest(mongo, mongo.StorageLink, s3SecretName); + + await k8sFactory.ApplyManifestAsync(manifest, mongo.KubernetesCluster.Kubeconfig!, ct); + + // Re-apply the CronJob so the mongodump image version stays in sync. + if (!string.IsNullOrEmpty(mongo.BackupSchedule) && mongo.StorageLink is not null && s3SecretName is not null) + { + string cronManifest = BuildScheduledBackupCronJobManifest(mongo, mongo.StorageLink, s3SecretName); + await k8sFactory.ApplyManifestAsync(cronManifest, mongo.KubernetesCluster.Kubeconfig!, ct); + } + } + + // ──────── Backup & Restore ──────── + + /// + /// Triggers an on-demand backup by applying a Kubernetes Job that runs mongodump + /// and uploads the gzip archive to the configured S3 bucket. The backup archive + /// is stored at {prefix}/{backupName}.archive in the bucket. + /// + public async Task BackupAsync(Guid tenantId, Guid mongoClusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + MongoCluster mongo = await db.MongoClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("MongoDB cluster not found."); + + if (!mongo.StorageLinkId.HasValue) + { + throw new InvalidOperationException( + "Backup storage is not configured for this cluster. Assign an S3 bucket first."); + } + + // Generate a unique backup name with timestamp. + + string backupName = $"{mongo.Name}-{DateTime.UtcNow:yyyyMMdd-HHmmss}"; + + MongoBackup backup = new() + { + Id = Guid.NewGuid(), + MongoClusterId = mongo.Id, + Name = backupName, + Type = MongoBackupType.OnDemand, + Status = MongoBackupStatus.Running + }; + + db.MongoBackups.Add(backup); + await db.SaveChangesAsync(ct); + + // Apply a Job that runs mongodump → S3 upload. + + string s3SecretName = $"{mongo.Name}-s3-credentials"; + StorageLink storageLink = await db.StorageLinks.FirstAsync(s => s.Id == mongo.StorageLinkId!.Value, ct); + + await EnsureStorageSecretsInK8sAsync( + tenantId, storageLink, s3SecretName, mongo.Namespace, mongo.KubernetesCluster.Kubeconfig!, ct); + + string manifest = BuildBackupManifest(backupName, mongo, storageLink, s3SecretName); + await k8sFactory.ApplyManifestAsync(manifest, mongo.KubernetesCluster.Kubeconfig!, ct); + + return backup; + } + + /// + /// Restores a MongoDB cluster from a previously taken backup. Creates a Kubernetes + /// Job that downloads the backup archive from S3 and runs mongorestore --drop against + /// the replica set. The sourceBackupId must refer to a completed OnDemand or Scheduled + /// backup whose archive exists in the configured S3 bucket. + /// + public async Task RestoreAsync( + Guid tenantId, Guid mongoClusterId, Guid sourceBackupId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + MongoCluster mongo = await db.MongoClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("MongoDB cluster not found."); + + if (!mongo.StorageLinkId.HasValue) + { + throw new InvalidOperationException( + "Restore requires backup storage configured on this cluster."); + } + + MongoBackup sourceBackup = await db.MongoBackups + .FirstOrDefaultAsync(b => b.Id == sourceBackupId && b.MongoClusterId == mongo.Id, ct) + ?? throw new InvalidOperationException("Backup record not found."); + + string restoreName = $"{mongo.Name}-restore-{DateTime.UtcNow:yyyyMMdd-HHmmss}"; + + MongoBackup restoreRecord = new() + { + Id = Guid.NewGuid(), + MongoClusterId = mongo.Id, + Name = restoreName, + Type = MongoBackupType.OnDemand, + Status = MongoBackupStatus.Running + }; + + db.MongoBackups.Add(restoreRecord); + mongo.Status = MongoClusterStatus.Restoring; + await db.SaveChangesAsync(ct); + + string s3SecretName = $"{mongo.Name}-s3-credentials"; + StorageLink storageLink = await db.StorageLinks.FirstAsync(s => s.Id == mongo.StorageLinkId!.Value, ct); + + await EnsureStorageSecretsInK8sAsync( + tenantId, storageLink, s3SecretName, mongo.Namespace, mongo.KubernetesCluster.Kubeconfig!, ct); + + string manifest = BuildRestoreManifest(restoreName, mongo, storageLink, sourceBackup.Name, s3SecretName); + await k8sFactory.ApplyManifestAsync(manifest, mongo.KubernetesCluster.Kubeconfig!, ct); + + return restoreRecord; + } + + /// + /// Creates a new MongoDB cluster from an existing backup. The new cluster is + /// provisioned fresh (new admin credentials, RBAC, StatefulSet), then a restore + /// Job is applied that downloads the backup archive from S3 and runs mongorestore + /// into the new cluster's replica set. + /// + public async Task RestoreToNewClusterAsync( + Guid tenantId, Guid sourceMongoClusterId, Guid sourceBackupId, string newName, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + MongoCluster source = await db.MongoClusters + .Include(c => c.KubernetesCluster) + .Include(c => c.StorageLink) + .FirstOrDefaultAsync(c => c.Id == sourceMongoClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Source MongoDB cluster not found."); + + if (!source.StorageLinkId.HasValue) + { + throw new InvalidOperationException( + "Restore requires backup storage configured on the source cluster."); + } + + MongoBackup sourceBackup = await db.MongoBackups + .FirstOrDefaultAsync(b => b.Id == sourceBackupId && b.MongoClusterId == source.Id, ct) + ?? throw new InvalidOperationException("Backup record not found."); + + StorageLink storageLink = await db.StorageLinks + .FirstAsync(s => s.Id == source.StorageLinkId!.Value, ct); + + MongoCluster newCluster = await CreateClusterAsync( + tenantId, source.KubernetesClusterId, newName, source.Namespace, + source.Members, source.StorageSize, source.StorageLinkId, + source.BackupSchedule, source.RetentionDays, ct); + + string restoreName = $"{newName}-restore-{DateTime.UtcNow:yyyyMMdd-HHmmss}"; + string s3SecretName = $"{newName}-s3-credentials"; + + await EnsureStorageSecretsInK8sAsync( + tenantId, storageLink, s3SecretName, newCluster.Namespace, + source.KubernetesCluster.Kubeconfig!, ct); + + // Cross-cluster restore: download the backup from the source cluster's S3 path, + // restore into the new cluster's service using the new cluster's admin credentials. + string manifest = BuildCrossClusterRestoreManifest( + restoreName, newCluster, source.Name, storageLink, sourceBackup.Name, s3SecretName); + await k8sFactory.ApplyManifestAsync(manifest, source.KubernetesCluster.Kubeconfig!, ct); + + return newCluster; + } + + /// + /// Restores a MongoDB backup to an external MongoDB server via a connection URI. + /// Creates a Kubernetes Job that downloads the archive from S3 and runs mongorestore + /// against the provided URI — useful for migrating data to an on-premise or cloud- + /// hosted MongoDB instance without creating a new Community Operator cluster. + /// + public async Task RestoreToExternalAsync( + Guid tenantId, Guid mongoClusterId, Guid sourceBackupId, string externalMongoUri, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + MongoCluster mongo = await db.MongoClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("MongoDB cluster not found."); + + if (!mongo.StorageLinkId.HasValue) + { + throw new InvalidOperationException( + "Restore requires backup storage configured on this cluster."); + } + + MongoBackup sourceBackup = await db.MongoBackups + .FirstOrDefaultAsync(b => b.Id == sourceBackupId && b.MongoClusterId == mongo.Id, ct) + ?? throw new InvalidOperationException("Backup record not found."); + + StorageLink storageLink = await db.StorageLinks + .FirstAsync(s => s.Id == mongo.StorageLinkId!.Value, ct); + + string restoreName = $"{mongo.Name}-ext-restore-{DateTime.UtcNow:yyyyMMdd-HHmmss}"; + string s3SecretName = $"{mongo.Name}-s3-credentials"; + + await EnsureStorageSecretsInK8sAsync( + tenantId, storageLink, s3SecretName, mongo.Namespace, + mongo.KubernetesCluster.Kubeconfig!, ct); + + string manifest = BuildExternalRestoreManifest( + restoreName, mongo, storageLink, sourceBackup.Name, s3SecretName, externalMongoUri); + await k8sFactory.ApplyManifestAsync(manifest, mongo.KubernetesCluster.Kubeconfig!, ct); + } + + // ──────── Database Management ──────── + + /// + /// Creates a new database and user within a running MongoDB cluster. + /// Generates a random password, runs createUser via mongosh on the primary pod, + /// then stores the connection credentials in the vault tagged for Kubernetes sync. + /// + public async Task CreateDatabaseAsync( + Guid tenantId, Guid mongoClusterId, string databaseName, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + MongoCluster mongo = await db.MongoClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("MongoDB cluster not found."); + + string owner = $"{databaseName}_owner"; + string password = GeneratePassword(); + + // Create the database record. + + MongoDatabase database = new() + { + Id = Guid.NewGuid(), + MongoClusterId = mongo.Id, + Name = databaseName, + Owner = owner, + Status = MongoDatabaseStatus.Creating + }; + + db.MongoDatabases.Add(database); + await db.SaveChangesAsync(ct); + + // Read the cluster admin password from the vault. + + string? adminPassword = await vaultService.GetMongoClusterSecretValueAsync( + tenantId, mongo.Id, "ADMIN_PASSWORD", ct); + + // Execute mongosh command on the primary to create the user with readWrite role. + + string script = $$""" + db.getSiblingDB("{{databaseName}}").createUser({ + user: "{{owner}}", + pwd: "{{password}}", + roles: [{ role: "readWrite", db: "{{databaseName}}" }] + }) + """; + + await k8sFactory.ExecuteMongoAsync( + mongo.Name, mongo.Namespace, script, mongo.KubernetesCluster.Kubeconfig!, + username: "admin", password: adminPassword, ct); + + // Mark as ready. + + database.Status = MongoDatabaseStatus.Ready; + await db.SaveChangesAsync(ct); + + // Store connection credentials in the vault, tagged for K8s sync. + + string k8sSecretName = $"{mongo.Name}-{databaseName}-credentials"; + + // Community Operator creates a headless service named {name}-svc. + // The replica set name equals the MongoDBCommunity resource name. + string host = $"{mongo.Name}-svc.{mongo.Namespace}.svc.cluster.local"; + + await vaultService.InitializeVaultAsync(tenantId, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "HOST", host, k8sSecretName, mongo.Namespace, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "PORT", "27017", k8sSecretName, mongo.Namespace, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "DATABASE", databaseName, k8sSecretName, mongo.Namespace, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "USERNAME", owner, k8sSecretName, mongo.Namespace, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "PASSWORD", password, k8sSecretName, mongo.Namespace, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "CONNECTION_STRING", + $"mongodb://{owner}:{password}@{host}:27017/{databaseName}?replicaSet={mongo.Name}&authSource={databaseName}", + k8sSecretName, mongo.Namespace, ct); + + return database; + } + + /// + /// Deletes a database user from a MongoDB cluster. Drops the user + /// via mongosh and removes all associated vault secrets. + /// + public async Task DeleteDatabaseAsync( + Guid tenantId, Guid mongoClusterId, Guid databaseId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + MongoCluster mongo = await db.MongoClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("MongoDB cluster not found."); + + MongoDatabase database = await db.MongoDatabases + .FirstOrDefaultAsync(d => d.Id == databaseId && d.MongoClusterId == mongo.Id, ct) + ?? throw new InvalidOperationException("Database not found."); + + // Drop the user from MongoDB. We don't drop the database itself because + // MongoDB doesn't require explicit database creation — it exists implicitly. + + string? adminPassword = await vaultService.GetMongoClusterSecretValueAsync( + tenantId, mongo.Id, "ADMIN_PASSWORD", ct); + + string script = $$""" + db.getSiblingDB("{{database.Name}}").dropUser("{{database.Owner}}") + """; + + await k8sFactory.ExecuteMongoAsync( + mongo.Name, mongo.Namespace, script, mongo.KubernetesCluster.Kubeconfig!, + username: "admin", password: adminPassword, ct); + + // Remove vault secrets. + + await DeleteDatabaseSecretsAsync(database.Id, ct); + + // Remove the database record. + + db.MongoDatabases.Remove(database); + await db.SaveChangesAsync(ct); + } + + // ──────── Queries ──────── + + /// + /// Gets all managed MongoDB clusters for a tenant, including their databases. + /// + public async Task> GetClustersAsync(Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.MongoClusters + .Include(c => c.KubernetesCluster) + .ThenInclude(k => k.Environment) + .Include(c => c.StorageLink) + .Include(c => c.Databases) + .Include(c => c.Backups.OrderByDescending(b => b.StartedAt).Take(5)) + .Where(c => c.TenantId == tenantId) + .OrderBy(c => c.Name) + .ToListAsync(ct); + } + + /// + /// Gets the live detail for a Percona MongoDB cluster including pod status. + /// Queries the PerconaServerMongoDB CRD status and lists pods. + /// + public async Task GetClusterDetailAsync( + Guid tenantId, Guid mongoClusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + MongoCluster? cluster = await db.MongoClusters + .Include(c => c.KubernetesCluster) + .Include(c => c.StorageLink) + .Include(c => c.Databases) + .Include(c => c.Backups.OrderByDescending(b => b.StartedAt).Take(20)) + .FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct); + + if (cluster is null) + { + return null; + } + + MongoClusterDetail detail = new() + { + Cluster = cluster, + Phase = "Querying...", + Backups = cluster.Backups.OrderByDescending(b => b.StartedAt).ToList() + }; + + bool clusterReachable = false; + string podsJsonForReconcile = string.Empty; + + try + { + // Query the MongoDBCommunity CRD status. + string clusterJson = await k8sFactory.GetJsonAsync( + $"mongodbcommunity.mongodbcommunity.mongodb.com/{cluster.Name}", + cluster.Namespace, cluster.KubernetesCluster.Kubeconfig!, ct: ct); + + ParseClusterStatus(clusterJson, detail); + + // Query all pods in the namespace and filter by StatefulSet naming convention + // ({name}-N). Label-selector-based queries are unreliable across Community + // Operator versions (some use `app`, some use `app.kubernetes.io/name`). + string podsJson = await k8sFactory.GetJsonAsync( + "pods", cluster.Namespace, cluster.KubernetesCluster.Kubeconfig!, ct: ct); + + detail.Pods = ParsePodList(podsJson, cluster.Name); + podsJsonForReconcile = podsJson; // reuse below for backup status reconciliation + + // Reconcile DB status now that we have live K8s phase information. + await ReconcileMongoClusterStatusAsync(cluster, detail, ct); + + clusterReachable = true; + } + catch (Exception) + { + detail.Phase = "Unable to reach cluster"; + } + + // Fetch backup job status independently — has its own try/catch so a cluster-query + // failure above does not prevent backup status from being updated. + // Scan K8s Jobs for completed backup jobs so scheduled backups appear in the UI + // even when they were not triggered through EntKube. + List k8sBackups = await FetchMongoBackupsFromK8sAsync(cluster, ct); + if (k8sBackups.Count > 0) + detail.Backups = k8sBackups; + + await PersistMongoBackupsAsync(cluster.Id, k8sBackups, ct); + + // When K8s is reachable, reconcile Running backups using live pod/job data. + if (clusterReachable) + await ReconcileRunningBackupsFromK8sAsync(cluster, podsJsonForReconcile, ct); + + // Always run the stale check regardless of K8s reachability. + // A backup still Running after 25 h is certainly finished (TTL is 24 h). + await MarkStaleRunningBackupsAsync(cluster.Id, ct); + + // Always reload from DB after persisting so backup objects carry real GUIDs. + // K8s-fetched records have Id=Guid.Empty; DB records are what restore methods need. + using ApplicationDbContext backupsDb = dbFactory.CreateDbContext(); + List dbBackups = await backupsDb.MongoBackups + .Where(b => b.MongoClusterId == cluster.Id) + .OrderByDescending(b => b.StartedAt) + .Take(20) + .ToListAsync(ct); + + if (dbBackups.Count > 0) + detail.Backups = dbBackups; + + return detail; + } + + private async Task ReconcileMongoClusterStatusAsync( + MongoCluster cluster, MongoClusterDetail detail, CancellationToken ct) + { + MongoClusterStatus? newStatus = DetermineMongoStatusFromPhase( + detail.Phase, detail.ReadyMembers, cluster.Members, cluster.Status); + + if (newStatus is null || newStatus == cluster.Status) + return; + + try + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + MongoCluster? tracked = await db.MongoClusters.FindAsync([cluster.Id], ct); + if (tracked is not null && tracked.Status != newStatus.Value) + { + tracked.Status = newStatus.Value; + await db.SaveChangesAsync(ct); + cluster.Status = newStatus.Value; + } + } + catch { } + } + + private static MongoClusterStatus? DetermineMongoStatusFromPhase( + string phase, int readyMembers, int expectedMembers, MongoClusterStatus currentStatus) + { + string lower = phase.ToLowerInvariant(); + + if (lower == "running" && readyMembers >= expectedMembers) + { + if (currentStatus is MongoClusterStatus.Creating + or MongoClusterStatus.Restoring + or MongoClusterStatus.Failed) + { + return MongoClusterStatus.Running; + } + } + + if (lower == "failed") + { + if (currentStatus is not MongoClusterStatus.Failed and not MongoClusterStatus.Deleting) + return MongoClusterStatus.Failed; + } + + return null; + } + + private async Task> FetchMongoBackupsFromK8sAsync( + MongoCluster cluster, CancellationToken ct) + { + try + { + // Query all jobs in the namespace — label filter is avoided because CronJob-spawned + // jobs may not carry the entkube.io/mongo-cluster label if the CronJob was deployed + // before that label was added to the jobTemplate. We identify our jobs by label OR + // by ownerReference pointing to this cluster's scheduled-backup CronJob. + string jobsJson = await k8sFactory.GetJsonAsync( + "jobs", cluster.Namespace, cluster.KubernetesCluster.Kubeconfig!, ct: ct); + + using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(jobsJson); + System.Text.Json.JsonElement root = doc.RootElement; + + if (!root.TryGetProperty("items", out System.Text.Json.JsonElement items)) + return []; + + string scheduledCronJobName = $"{cluster.Name}-scheduled-backup"; + List result = []; + + foreach (System.Text.Json.JsonElement item in items.EnumerateArray()) + { + if (!item.TryGetProperty("metadata", out System.Text.Json.JsonElement meta)) + continue; + + string? name = meta.TryGetProperty("name", out System.Text.Json.JsonElement nameEl) + ? nameEl.GetString() : null; + if (string.IsNullOrEmpty(name)) + continue; + + bool hasClusterLabel = false; + bool isScheduled = false; + + if (meta.TryGetProperty("labels", out System.Text.Json.JsonElement labels)) + { + // Skip restore jobs. + if (labels.TryGetProperty("entkube.io/restore-source", out _) + || labels.TryGetProperty("entkube.io/restore-type", out _)) + continue; + + if (labels.TryGetProperty("entkube.io/mongo-cluster", out System.Text.Json.JsonElement clusterLabel) + && clusterLabel.GetString() == cluster.Name) + hasClusterLabel = true; + } + + // Check ownerReferences: jobs from our scheduled CronJob belong to this cluster + // even when the label is absent (older CronJob deployments). + if (meta.TryGetProperty("ownerReferences", out System.Text.Json.JsonElement owners) + && owners.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (System.Text.Json.JsonElement owner in owners.EnumerateArray()) + { + bool isCronJob = owner.TryGetProperty("kind", out System.Text.Json.JsonElement kindEl) + && kindEl.GetString() == "CronJob"; + bool isOurCronJob = owner.TryGetProperty("name", out System.Text.Json.JsonElement ownerNameEl) + && ownerNameEl.GetString() == scheduledCronJobName; + + if (isCronJob) + { + isScheduled = true; + if (isOurCronJob) + hasClusterLabel = true; // treat as belonging to this cluster + break; + } + } + } + + // Skip jobs that don't belong to this cluster. + if (!hasClusterLabel) + continue; + + MongoBackupStatus status = MongoBackupStatus.Running; + DateTime? startedAt = null; + DateTime? completedAt = null; + + if (item.TryGetProperty("status", out System.Text.Json.JsonElement jobStatus)) + { + if (jobStatus.TryGetProperty("startTime", out System.Text.Json.JsonElement startEl) + && DateTime.TryParse(startEl.GetString(), null, + System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsedStart)) + startedAt = parsedStart; + + if (jobStatus.TryGetProperty("completionTime", out System.Text.Json.JsonElement doneEl) + && DateTime.TryParse(doneEl.GetString(), null, + System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsedDone)) + completedAt = parsedDone; + + if (jobStatus.TryGetProperty("succeeded", out System.Text.Json.JsonElement succEl) + && succEl.TryGetInt32(out int succeededCount) && succeededCount > 0) + status = MongoBackupStatus.Completed; + else if (jobStatus.TryGetProperty("failed", out System.Text.Json.JsonElement failEl) + && failEl.TryGetInt32(out int failedCount) && failedCount > 0) + status = MongoBackupStatus.Failed; + else if (completedAt.HasValue) + { + // completionTime is set but succeeded/failed counts not yet visible — + // check conditions array for a definitive Complete or Failed signal. + if (jobStatus.TryGetProperty("conditions", out System.Text.Json.JsonElement conds) + && conds.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (System.Text.Json.JsonElement cond in conds.EnumerateArray()) + { + bool isTrue = cond.TryGetProperty("status", out System.Text.Json.JsonElement condStatus) + && condStatus.GetString() == "True"; + if (!isTrue) continue; + if (cond.TryGetProperty("type", out System.Text.Json.JsonElement condType)) + { + string? t = condType.GetString(); + if (t == "Complete") { status = MongoBackupStatus.Completed; break; } + if (t == "Failed") { status = MongoBackupStatus.Failed; break; } + } + } + } + // If still undecided but completionTime is set, treat as completed. + if (status == MongoBackupStatus.Running) + status = MongoBackupStatus.Completed; + } + } + + result.Add(new MongoBackup + { + Id = Guid.Empty, + MongoClusterId = cluster.Id, + Name = name, + Type = isScheduled ? MongoBackupType.Scheduled : MongoBackupType.OnDemand, + Status = status, + StartedAt = startedAt ?? DateTime.UtcNow, + CompletedAt = completedAt + }); + } + + return [.. result.OrderByDescending(b => b.StartedAt).Take(20)]; + } + catch + { + return []; + } + } + + private async Task PersistMongoBackupsAsync( + Guid mongoClusterId, List k8sBackups, CancellationToken ct) + { + if (k8sBackups.Count == 0) + return; + + foreach (MongoBackup k8s in k8sBackups) + { + if (k8s.Id != Guid.Empty) + continue; + + try + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + MongoBackup? existing = await db.MongoBackups + .FirstOrDefaultAsync( + b => b.MongoClusterId == mongoClusterId && b.Name == k8s.Name, ct); + + if (existing is null) + { + db.MongoBackups.Add(new MongoBackup + { + Id = Guid.NewGuid(), + MongoClusterId = mongoClusterId, + Name = k8s.Name, + Type = k8s.Type, + Status = k8s.Status, + StartedAt = k8s.StartedAt, + CompletedAt = k8s.CompletedAt + }); + await db.SaveChangesAsync(ct); + } + else if (existing.Status != k8s.Status) + { + existing.Status = k8s.Status; + if (k8s.Status != MongoBackupStatus.Running) + existing.CompletedAt = k8s.CompletedAt ?? DateTime.UtcNow; + await db.SaveChangesAsync(ct); + } + } + catch { } + } + } + + /// + /// Marks DB backup records still Running after 25 h as Completed. + /// Runs unconditionally (even when K8s is unreachable) because a backup that has been + /// Running for more than the job TTL (86400 s = 24 h) is certainly done. + /// + private async Task MarkStaleRunningBackupsAsync(Guid mongoClusterId, CancellationToken ct) + { + try + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + DateTime threshold = DateTime.UtcNow.AddHours(-25); + + List stale = await db.MongoBackups + .Where(b => b.MongoClusterId == mongoClusterId + && b.Status == MongoBackupStatus.Running + && b.StartedAt < threshold) + .ToListAsync(ct); + + if (stale.Count == 0) + return; + + foreach (MongoBackup backup in stale) + { + backup.Status = MongoBackupStatus.Completed; + backup.CompletedAt ??= backup.StartedAt.AddHours(1); + } + + await db.SaveChangesAsync(ct); + } + catch { } + } + + /// + /// Reconciles DB backup records still marked Running using two sources: + /// 1. The already-fetched pods JSON — K8s automatically labels job pods with + /// "batch.kubernetes.io/job-name" so we can read pod phase without a separate + /// jobs query and without any special RBAC for the jobs resource. + /// 2. A fallback all-jobs kubectl query for any backup not resolved via pods. + /// Only called when K8s is reachable so we don't falsely update during outages. + /// + private async Task ReconcileRunningBackupsFromK8sAsync( + MongoCluster cluster, string podsJson, CancellationToken ct) + { + try + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + List running = await db.MongoBackups + .Where(b => b.MongoClusterId == cluster.Id && b.Status == MongoBackupStatus.Running) + .ToListAsync(ct); + + if (running.Count == 0) + return; + + HashSet runningNames = running + .Select(b => b.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // ── Source 1: pod phases from the already-fetched pods JSON ── + // K8s labels every job pod with batch.kubernetes.io/job-name (1.21+) or job-name (older). + // Fallback: match by pod name prefix ({job-name}-{hash}) for distributions that omit labels. + Dictionary podPhaseByJobName = []; // job-name → best phase + + if (!string.IsNullOrEmpty(podsJson)) + { + try + { + using System.Text.Json.JsonDocument podsDoc = System.Text.Json.JsonDocument.Parse(podsJson); + if (podsDoc.RootElement.TryGetProperty("items", out System.Text.Json.JsonElement podItems)) + { + foreach (System.Text.Json.JsonElement pod in podItems.EnumerateArray()) + { + if (!pod.TryGetProperty("metadata", out System.Text.Json.JsonElement podMeta)) continue; + + string? phase = null; + if (pod.TryGetProperty("status", out System.Text.Json.JsonElement podStatus) + && podStatus.TryGetProperty("phase", out System.Text.Json.JsonElement phaseEl)) + phase = phaseEl.GetString(); + if (phase is null) continue; + + string? jobName = null; + + // Primary: label-based lookup (K8s 1.21+ and older variants). + if (podMeta.TryGetProperty("labels", out System.Text.Json.JsonElement podLabels)) + { + if (podLabels.TryGetProperty("batch.kubernetes.io/job-name", out System.Text.Json.JsonElement jn)) + jobName = jn.GetString(); + else if (podLabels.TryGetProperty("job-name", out System.Text.Json.JsonElement jn2)) + jobName = jn2.GetString(); + } + + // Fallback: name-prefix matching — pod names are "{job-name}-{5-char-hash}". + if (string.IsNullOrEmpty(jobName) + && podMeta.TryGetProperty("name", out System.Text.Json.JsonElement podNameEl)) + { + string? podName = podNameEl.GetString(); + if (!string.IsNullOrEmpty(podName)) + { + foreach (string candidate in runningNames) + { + if (podName.StartsWith(candidate + "-", StringComparison.OrdinalIgnoreCase)) + { + jobName = candidate; + break; + } + } + } + } + + if (string.IsNullOrEmpty(jobName)) continue; + + // Keep the "best" phase: Succeeded > Failed > anything else. + if (!podPhaseByJobName.TryGetValue(jobName, out string? bestSoFar) + || bestSoFar == "Running" + || (bestSoFar != "Succeeded" && phase == "Succeeded")) + { + podPhaseByJobName[jobName] = phase; + } + } + } + } + catch { } + } + + // ── Source 2: all-jobs query as fallback (no label filter) ── + Dictionary k8sJobs = []; + try + { + string allJobsJson = await k8sFactory.GetJsonAsync( + "jobs", cluster.Namespace, cluster.KubernetesCluster.Kubeconfig!, ct: ct); + + using System.Text.Json.JsonDocument jobsDoc = System.Text.Json.JsonDocument.Parse(allJobsJson); + if (jobsDoc.RootElement.TryGetProperty("items", out System.Text.Json.JsonElement jobItems)) + { + foreach (System.Text.Json.JsonElement item in jobItems.EnumerateArray()) + { + if (!item.TryGetProperty("metadata", out System.Text.Json.JsonElement meta)) continue; + if (!meta.TryGetProperty("name", out System.Text.Json.JsonElement nameEl)) continue; + string? name = nameEl.GetString(); + if (string.IsNullOrEmpty(name)) continue; + + bool succeeded = false, failed = false; + DateTime? completionTime = null; + + if (item.TryGetProperty("status", out System.Text.Json.JsonElement jobStatus)) + { + succeeded = jobStatus.TryGetProperty("succeeded", out System.Text.Json.JsonElement s) + && s.TryGetInt32(out int sv) && sv > 0; + failed = jobStatus.TryGetProperty("failed", out System.Text.Json.JsonElement f) + && f.TryGetInt32(out int fv) && fv > 0; + if (jobStatus.TryGetProperty("completionTime", out System.Text.Json.JsonElement ct2) + && DateTime.TryParse(ct2.GetString(), null, + System.Globalization.DateTimeStyles.RoundtripKind, out DateTime ct3)) + completionTime = ct3; + } + + k8sJobs[name] = (succeeded, failed, completionTime); + } + } + } + catch { } // jobs RBAC unavailable or other error — pods source above takes over + + bool changed = false; + + foreach (MongoBackup backup in running) + { + // Check pod phase first (most reliable, no extra kubectl call). + if (podPhaseByJobName.TryGetValue(backup.Name, out string? podPhase)) + { + if (podPhase == "Succeeded") + { + backup.Status = MongoBackupStatus.Completed; + backup.CompletedAt ??= DateTime.UtcNow; + changed = true; + continue; + } + if (podPhase == "Failed") + { + backup.Status = MongoBackupStatus.Failed; + changed = true; + continue; + } + // Pod still Running → leave as Running + continue; + } + + // Fall back to jobs query result. + if (k8sJobs.TryGetValue(backup.Name, out var js)) + { + if (js.Succeeded || js.CompletionTime.HasValue) + { + backup.Status = MongoBackupStatus.Completed; + backup.CompletedAt ??= js.CompletionTime ?? DateTime.UtcNow; + changed = true; + } + else if (js.Failed) + { + backup.Status = MongoBackupStatus.Failed; + changed = true; + } + // else genuinely still active → leave as Running + continue; + } + + // Job gone from K8s (TTL-deleted) but not yet old enough for stale check → + // MarkStaleRunningBackupsAsync handles this unconditionally after this method. + } + + if (changed) + await db.SaveChangesAsync(ct); + } + catch { } + } + + // ──────── Private Helpers ──────── + + /// + /// Ensures the S3 storage credentials are available as a Kubernetes Secret + /// in the target namespace so backup Jobs can access the bucket. + /// + private async Task EnsureStorageSecretsInK8sAsync( + Guid tenantId, StorageLink storageLink, string secretName, string ns, + string kubeconfig, CancellationToken ct) + { + List secrets = await vaultService.GetStorageLinkSecretsAsync(tenantId, storageLink.Id, ct); + + foreach (VaultSecret secret in secrets) + { + if (secret.Name == "ACCESS_KEY" || secret.Name == "SECRET_KEY") + { + await vaultService.ConfigureKubernetesSyncAsync(secret.Id, true, secretName, ns, ct); + } + } + + string? accessKey = await vaultService.GetStorageLinkSecretValueAsync(tenantId, storageLink.Id, "ACCESS_KEY", ct); + string? secretKey = await vaultService.GetStorageLinkSecretValueAsync(tenantId, storageLink.Id, "SECRET_KEY", ct); + + if (accessKey is null || secretKey is null) + { + throw new InvalidOperationException( + "S3 credentials (ACCESS_KEY and SECRET_KEY) are missing from the storage link vault. " + + "Add them via the Storage tab before creating a backup-enabled cluster."); + } + + string accessKeyB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(accessKey)); + string secretKeyB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(secretKey)); + + StringBuilder sb = new(); + sb.AppendLine("apiVersion: v1"); + sb.AppendLine("kind: Secret"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {secretName}"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("type: Opaque"); + sb.AppendLine("data:"); + sb.AppendLine($" ACCESS_KEY: {accessKeyB64}"); + sb.AppendLine($" SECRET_KEY: {secretKeyB64}"); + + await k8sFactory.ApplyManifestAsync(sb.ToString(), kubeconfig, ct); + } + + /// + /// Stores a single database credential in the vault with K8s sync configuration. + /// + private async Task StoreDatabaseSecretAsync( + Guid tenantId, Guid databaseId, string name, string value, + string k8sSecretName, string k8sNamespace, CancellationToken ct) + { + await vaultService.SetMongoDatabaseSecretAsync( + tenantId, databaseId, name, value, k8sSecretName, k8sNamespace, ct); + } + + /// + /// Removes all vault secrets associated with a MongoDB database. + /// + private async Task DeleteDatabaseSecretsAsync(Guid databaseId, CancellationToken ct) + { + using ApplicationDbContext secretDb = dbFactory.CreateDbContext(); + + List secrets = await secretDb.VaultSecrets + .Where(s => s.MongoDatabaseId == databaseId) + .ToListAsync(ct); + + secretDb.VaultSecrets.RemoveRange(secrets); + await secretDb.SaveChangesAsync(ct); + } + + /// + /// Removes all vault secrets scoped to a managed MongoDB cluster (e.g. the admin password). + /// + private async Task DeleteClusterSecretsAsync(Guid mongoClusterId, CancellationToken ct) + { + using ApplicationDbContext secretDb = dbFactory.CreateDbContext(); + + List secrets = await secretDb.VaultSecrets + .Where(s => s.MongoClusterId == mongoClusterId) + .ToListAsync(ct); + + secretDb.VaultSecrets.RemoveRange(secrets); + await secretDb.SaveChangesAsync(ct); + } + + /// + /// Generates a secure random password for database users. + /// + private static string GeneratePassword() + { + byte[] bytes = RandomNumberGenerator.GetBytes(24); + return Convert.ToBase64String(bytes).Replace("+", "x").Replace("/", "y")[..32]; + } + + // ──────── Manifest Builders ──────── + + /// + /// Builds a multi-document manifest that creates the ServiceAccount, Role, and + /// RoleBinding required by the MongoDB agent running inside each pod. + /// + /// The Community Operator provisions these resources only in its own installation + /// namespace. When the MongoDB cluster lives in a different namespace the agent's + /// readiness probe panics with "secrets forbidden" because the ServiceAccount has + /// no permissions there. Applying these three resources fixes that. + /// + /// Permissions mirror the mongodb-database Role in the operator's Helm chart: + /// - secrets: get (agent reads the mongodb-config automation-config secret) + /// - pods: get, patch, delete (agent manages pod lifecycle during elections) + /// + private static string BuildServiceAccountManifest(string ns) + { + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: v1"); + sb.AppendLine("kind: ServiceAccount"); + sb.AppendLine("metadata:"); + sb.AppendLine(" name: mongodb-database"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("---"); + sb.AppendLine("apiVersion: rbac.authorization.k8s.io/v1"); + sb.AppendLine("kind: Role"); + sb.AppendLine("metadata:"); + sb.AppendLine(" name: mongodb-database"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("rules:"); + sb.AppendLine(" - apiGroups: [\"\"]"); + sb.AppendLine(" resources: [\"secrets\"]"); + sb.AppendLine(" verbs: [\"get\"]"); + sb.AppendLine(" - apiGroups: [\"\"]"); + sb.AppendLine(" resources: [\"pods\"]"); + sb.AppendLine(" verbs: [\"get\", \"patch\", \"delete\"]"); + sb.AppendLine("---"); + sb.AppendLine("apiVersion: rbac.authorization.k8s.io/v1"); + sb.AppendLine("kind: RoleBinding"); + sb.AppendLine("metadata:"); + sb.AppendLine(" name: mongodb-database"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("subjects:"); + sb.AppendLine(" - kind: ServiceAccount"); + sb.AppendLine(" name: mongodb-database"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("roleRef:"); + sb.AppendLine(" kind: Role"); + sb.AppendLine(" name: mongodb-database"); + sb.AppendLine(" apiGroup: rbac.authorization.k8s.io"); + + return sb.ToString(); + } + + /// + /// Builds the K8s Secret manifest that holds the MongoDB admin password. + /// Must be applied before the MongoDBCommunity CRD — the operator reads this + /// Secret during startup to configure SCRAM credentials. + /// + private static string BuildAdminSecretManifest(string name, string ns, string password) + { + StringBuilder sb = new(); + sb.AppendLine("apiVersion: v1"); + sb.AppendLine("kind: Secret"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {name}-admin-password"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("type: Opaque"); + sb.AppendLine("stringData:"); + sb.AppendLine($" password: \"{password}\""); + return sb.ToString(); + } + + /// + /// Builds the MongoDBCommunity CRD manifest. Defines an admin user referencing the + /// pre-existing {name}-admin-password Secret so the operator can configure SCRAM + /// authentication and create the StatefulSet. + /// + private static string BuildClusterManifest(MongoCluster mongo, StorageLink? storageLink, string? s3SecretName) + { + // Community Operator requires a full X.Y.Z version; coerce X.Y → X.Y.0. + string version = mongo.MongoVersion.Count(c => c == '.') < 2 + ? mongo.MongoVersion + ".0" + : mongo.MongoVersion; + + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: mongodbcommunity.mongodb.com/v1"); + sb.AppendLine("kind: MongoDBCommunity"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {mongo.Name}"); + sb.AppendLine($" namespace: {mongo.Namespace}"); + sb.AppendLine("spec:"); + sb.AppendLine($" members: {mongo.Members}"); + sb.AppendLine(" type: ReplicaSet"); + sb.AppendLine($" version: \"{version}\""); + sb.AppendLine(" security:"); + sb.AppendLine(" authentication:"); + sb.AppendLine(" modes: [\"SCRAM\"]"); + sb.AppendLine(" users:"); + sb.AppendLine(" - name: admin"); + sb.AppendLine(" db: admin"); + sb.AppendLine(" passwordSecretRef:"); + sb.AppendLine($" name: {mongo.Name}-admin-password"); + sb.AppendLine($" scramCredentialsSecretName: {mongo.Name}-scram"); + sb.AppendLine(" roles:"); + sb.AppendLine(" - name: clusterAdmin"); + sb.AppendLine(" db: admin"); + sb.AppendLine(" - name: userAdminAnyDatabase"); + sb.AppendLine(" db: admin"); + sb.AppendLine(" - name: readWriteAnyDatabase"); + sb.AppendLine(" db: admin"); + sb.AppendLine(" - name: dbAdminAnyDatabase"); + sb.AppendLine(" db: admin"); + sb.AppendLine(" statefulSet:"); + sb.AppendLine(" spec:"); + sb.AppendLine(" volumeClaimTemplates:"); + sb.AppendLine(" - metadata:"); + sb.AppendLine(" name: data-volume"); + sb.AppendLine(" spec:"); + sb.AppendLine(" accessModes: [\"ReadWriteOnce\"]"); + sb.AppendLine(" resources:"); + sb.AppendLine(" requests:"); + sb.AppendLine($" storage: {mongo.StorageSize}"); + + return sb.ToString(); + } + + /// + /// Builds a Kubernetes Job manifest that runs mongodump and uploads the gzip + /// archive to S3. Uses a two-stage approach: an init container runs mongodump + /// (authenticating via the admin Secret) into a shared emptyDir volume, then the + /// main container uploads via aws-cli. Archive stored at {bucket}/{name}/{backupName}.archive. + /// + private static string BuildBackupManifest( + string backupName, MongoCluster mongo, StorageLink storageLink, string s3SecretName) + { + string host = $"{mongo.Name}-svc.{mongo.Namespace}.svc.cluster.local"; + string mongoUri = $"mongodb://admin:${{MONGO_ADMIN_PASSWORD}}@{host}:27017/?authSource=admin&replicaSet={mongo.Name}"; + string s3Path = $"s3://{storageLink.BucketName}/{mongo.Name}/{backupName}.archive"; + + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: batch/v1"); + sb.AppendLine("kind: Job"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {backupName}"); + sb.AppendLine($" namespace: {mongo.Namespace}"); + sb.AppendLine(" labels:"); + sb.AppendLine($" entkube.io/mongo-cluster: {mongo.Name}"); + sb.AppendLine($" entkube.io/backup-type: on-demand"); + sb.AppendLine("spec:"); + sb.AppendLine(" backoffLimit: 0"); + sb.AppendLine(" ttlSecondsAfterFinished: 86400"); + sb.AppendLine(" template:"); + sb.AppendLine(" spec:"); + sb.AppendLine(" restartPolicy: Never"); + sb.AppendLine(" volumes:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" emptyDir: {}"); + sb.AppendLine(" initContainers:"); + sb.AppendLine(" - name: dump"); + sb.AppendLine($" image: mongo:{mongo.MongoVersion}"); + sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]"); + sb.AppendLine($" args: [\"mongodump --uri=\\\"{mongoUri}\\\" --gzip --archive=/backup/dump.archive\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {mongo.Name}-admin-password"); + sb.AppendLine(" key: password"); + sb.AppendLine(" volumeMounts:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" mountPath: /backup"); + sb.AppendLine(" containers:"); + sb.AppendLine(" - name: upload"); + sb.AppendLine(" image: amazon/aws-cli"); + sb.AppendLine(" command: [\"aws\", \"s3\", \"cp\", \"/backup/dump.archive\","); + sb.AppendLine($" \"{s3Path}\", \"--endpoint-url\", \"{storageLink.Endpoint}\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: AWS_ACCESS_KEY_ID"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: ACCESS_KEY"); + sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: SECRET_KEY"); + sb.AppendLine(" - name: AWS_DEFAULT_REGION"); + sb.AppendLine($" value: \"{storageLink.Region ?? "us-east-1"}\""); + sb.AppendLine(" volumeMounts:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" mountPath: /backup"); + + return sb.ToString(); + } + + /// + /// Builds a Kubernetes Job manifest that downloads a backup archive from S3 and + /// runs mongorestore --drop. The init container fetches the archive via aws-cli, + /// then the main container runs mongorestore (authenticated via the admin Secret). + /// + private static string BuildRestoreManifest( + string restoreName, MongoCluster mongo, StorageLink storageLink, + string sourceBackupName, string s3SecretName) + { + string host = $"{mongo.Name}-svc.{mongo.Namespace}.svc.cluster.local"; + string mongoUri = $"mongodb://admin:${{MONGO_ADMIN_PASSWORD}}@{host}:27017/?authSource=admin&replicaSet={mongo.Name}"; + string mongoshUri = $"mongodb://{host}:27017/?replicaSet={mongo.Name}"; + string s3Path = $"s3://{storageLink.BucketName}/{mongo.Name}/{sourceBackupName}.archive"; + + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: batch/v1"); + sb.AppendLine("kind: Job"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {restoreName}"); + sb.AppendLine($" namespace: {mongo.Namespace}"); + sb.AppendLine(" labels:"); + sb.AppendLine($" entkube.io/mongo-cluster: {mongo.Name}"); + sb.AppendLine($" entkube.io/restore-source: {sourceBackupName}"); + sb.AppendLine("spec:"); + sb.AppendLine(" backoffLimit: 0"); + sb.AppendLine(" ttlSecondsAfterFinished: 86400"); + sb.AppendLine(" template:"); + sb.AppendLine(" spec:"); + sb.AppendLine(" restartPolicy: Never"); + sb.AppendLine(" volumes:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" emptyDir: {}"); + sb.AppendLine(" initContainers:"); + sb.AppendLine(" - name: download"); + sb.AppendLine(" image: amazon/aws-cli"); + sb.AppendLine(" command: [\"aws\", \"s3\", \"cp\","); + sb.AppendLine($" \"{s3Path}\", \"/backup/dump.archive\", \"--endpoint-url\", \"{storageLink.Endpoint}\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: AWS_ACCESS_KEY_ID"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: ACCESS_KEY"); + sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: SECRET_KEY"); + sb.AppendLine(" - name: AWS_DEFAULT_REGION"); + sb.AppendLine($" value: \"{storageLink.Region ?? "us-east-1"}\""); + sb.AppendLine(" volumeMounts:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" mountPath: /backup"); + sb.AppendLine(" - name: wait-for-mongo"); + sb.AppendLine($" image: mongo:{mongo.MongoVersion}"); + sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]"); + sb.AppendLine($" args: [\"until mongosh \\\"{mongoshUri}\\\" --username admin --password \\\"${{MONGO_ADMIN_PASSWORD}}\\\" --authenticationDatabase admin --eval 'db.adminCommand({{ping:1}})' --quiet; do echo 'waiting for MongoDB...'; sleep 5; done\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {mongo.Name}-admin-password"); + sb.AppendLine(" key: password"); + sb.AppendLine(" containers:"); + sb.AppendLine(" - name: restore"); + sb.AppendLine($" image: mongo:{mongo.MongoVersion}"); + sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]"); + sb.AppendLine($" args: [\"mongorestore --uri=\\\"{mongoUri}\\\" --gzip --archive=/backup/dump.archive --drop\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {mongo.Name}-admin-password"); + sb.AppendLine(" key: password"); + sb.AppendLine(" volumeMounts:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" mountPath: /backup"); + + return sb.ToString(); + } + + /// + /// Builds a restore Job that downloads a backup from the SOURCE cluster's S3 path + /// and restores it into the TARGET cluster's service. Used for "restore to new cluster" + /// where the source and target are different clusters. + /// + private static string BuildCrossClusterRestoreManifest( + string restoreName, MongoCluster targetCluster, string sourceClusterName, + StorageLink storageLink, string sourceBackupName, string s3SecretName) + { + string host = $"{targetCluster.Name}-svc.{targetCluster.Namespace}.svc.cluster.local"; + string mongoUri = $"mongodb://admin:${{MONGO_ADMIN_PASSWORD}}@{host}:27017/?authSource=admin&replicaSet={targetCluster.Name}"; + string mongoshUri = $"mongodb://{host}:27017/?replicaSet={targetCluster.Name}"; + string s3Path = $"s3://{storageLink.BucketName}/{sourceClusterName}/{sourceBackupName}.archive"; + + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: batch/v1"); + sb.AppendLine("kind: Job"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {restoreName}"); + sb.AppendLine($" namespace: {targetCluster.Namespace}"); + sb.AppendLine(" labels:"); + sb.AppendLine($" entkube.io/mongo-cluster: {targetCluster.Name}"); + sb.AppendLine($" entkube.io/restore-source: {sourceBackupName}"); + sb.AppendLine("spec:"); + sb.AppendLine(" backoffLimit: 2"); + sb.AppendLine(" ttlSecondsAfterFinished: 86400"); + sb.AppendLine(" template:"); + sb.AppendLine(" spec:"); + sb.AppendLine(" restartPolicy: Never"); + sb.AppendLine(" volumes:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" emptyDir: {}"); + sb.AppendLine(" initContainers:"); + sb.AppendLine(" - name: download"); + sb.AppendLine(" image: amazon/aws-cli"); + sb.AppendLine(" command: [\"aws\", \"s3\", \"cp\","); + sb.AppendLine($" \"{s3Path}\", \"/backup/dump.archive\", \"--endpoint-url\", \"{storageLink.Endpoint}\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: AWS_ACCESS_KEY_ID"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: ACCESS_KEY"); + sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: SECRET_KEY"); + sb.AppendLine(" - name: AWS_DEFAULT_REGION"); + sb.AppendLine($" value: \"{storageLink.Region ?? "us-east-1"}\""); + sb.AppendLine(" volumeMounts:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" mountPath: /backup"); + sb.AppendLine(" - name: wait-for-mongo"); + sb.AppendLine($" image: mongo:{targetCluster.MongoVersion}"); + sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]"); + sb.AppendLine($" args: [\"until mongosh \\\"{mongoshUri}\\\" --username admin --password \\\"${{MONGO_ADMIN_PASSWORD}}\\\" --authenticationDatabase admin --eval 'db.adminCommand({{ping:1}})' --quiet; do echo 'waiting for MongoDB...'; sleep 5; done\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {targetCluster.Name}-admin-password"); + sb.AppendLine(" key: password"); + sb.AppendLine(" containers:"); + sb.AppendLine(" - name: restore"); + sb.AppendLine($" image: mongo:{targetCluster.MongoVersion}"); + sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]"); + sb.AppendLine($" args: [\"mongorestore --uri=\\\"{mongoUri}\\\" --gzip --archive=/backup/dump.archive --drop\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {targetCluster.Name}-admin-password"); + sb.AppendLine(" key: password"); + sb.AppendLine(" volumeMounts:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" mountPath: /backup"); + + return sb.ToString(); + } + + /// + /// Builds a restore Job that downloads a backup from S3 and runs mongorestore + /// against an external MongoDB URI. The URI must include credentials if auth is + /// required (e.g. mongodb://user:pass@host:27017/). No cluster-local admin + /// secret is needed — the URI carries all authentication information. + /// + private static string BuildExternalRestoreManifest( + string restoreName, MongoCluster sourceCluster, StorageLink storageLink, + string sourceBackupName, string s3SecretName, string externalMongoUri) + { + string s3Path = $"s3://{storageLink.BucketName}/{sourceCluster.Name}/{sourceBackupName}.archive"; + + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: batch/v1"); + sb.AppendLine("kind: Job"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {restoreName}"); + sb.AppendLine($" namespace: {sourceCluster.Namespace}"); + sb.AppendLine(" labels:"); + sb.AppendLine($" entkube.io/mongo-cluster: {sourceCluster.Name}"); + sb.AppendLine($" entkube.io/restore-type: external"); + sb.AppendLine("spec:"); + sb.AppendLine(" backoffLimit: 0"); + sb.AppendLine(" ttlSecondsAfterFinished: 86400"); + sb.AppendLine(" template:"); + sb.AppendLine(" spec:"); + sb.AppendLine(" restartPolicy: Never"); + sb.AppendLine(" volumes:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" emptyDir: {}"); + sb.AppendLine(" initContainers:"); + sb.AppendLine(" - name: download"); + sb.AppendLine(" image: amazon/aws-cli"); + sb.AppendLine(" command: [\"aws\", \"s3\", \"cp\","); + sb.AppendLine($" \"{s3Path}\", \"/backup/dump.archive\", \"--endpoint-url\", \"{storageLink.Endpoint}\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: AWS_ACCESS_KEY_ID"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: ACCESS_KEY"); + sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: SECRET_KEY"); + sb.AppendLine(" - name: AWS_DEFAULT_REGION"); + sb.AppendLine($" value: \"{storageLink.Region ?? "us-east-1"}\""); + sb.AppendLine(" volumeMounts:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" mountPath: /backup"); + sb.AppendLine(" containers:"); + sb.AppendLine(" - name: restore"); + sb.AppendLine($" image: mongo:{sourceCluster.MongoVersion}"); + sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]"); + sb.AppendLine($" args: [\"mongorestore --uri='{externalMongoUri}' --gzip --archive=/backup/dump.archive --drop\"]"); + sb.AppendLine(" volumeMounts:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" mountPath: /backup"); + + return sb.ToString(); + } + + /// + /// Builds a CronJob manifest that runs mongodump on a schedule and uploads + /// each archive to S3 at {bucket}/{name}/scheduled-{timestamp}.archive. + /// The CronJob uses the mongo image matching the cluster version so upgrades + /// keep the dump tool and server in sync. + /// + private static string BuildScheduledBackupCronJobManifest( + MongoCluster mongo, StorageLink storageLink, string s3SecretName) + { + string host = $"{mongo.Name}-svc.{mongo.Namespace}.svc.cluster.local"; + string mongoUri = $"mongodb://admin:${{MONGO_ADMIN_PASSWORD}}@{host}:27017/?authSource=admin&replicaSet={mongo.Name}"; + string s3Base = $"s3://{storageLink.BucketName}/{mongo.Name}"; + + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: batch/v1"); + sb.AppendLine("kind: CronJob"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {mongo.Name}-scheduled-backup"); + sb.AppendLine($" namespace: {mongo.Namespace}"); + sb.AppendLine(" labels:"); + sb.AppendLine($" entkube.io/mongo-cluster: {mongo.Name}"); + sb.AppendLine("spec:"); + // CNPG uses a 6-field cron (seconds included); Kubernetes CronJob only accepts 5 fields. + // Drop the leading seconds field when the schedule has 6 parts. + string cronSchedule = mongo.BackupSchedule!; + string[] parts = cronSchedule.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 6) cronSchedule = string.Join(' ', parts[1..]); + + sb.AppendLine($" schedule: \"{cronSchedule}\""); + sb.AppendLine(" concurrencyPolicy: Forbid"); + sb.AppendLine(" jobTemplate:"); + sb.AppendLine(" metadata:"); + sb.AppendLine(" labels:"); + sb.AppendLine($" entkube.io/mongo-cluster: {mongo.Name}"); + sb.AppendLine(" spec:"); + sb.AppendLine(" backoffLimit: 0"); + sb.AppendLine(" ttlSecondsAfterFinished: 86400"); + sb.AppendLine(" template:"); + sb.AppendLine(" spec:"); + sb.AppendLine(" restartPolicy: Never"); + sb.AppendLine(" volumes:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" emptyDir: {}"); + sb.AppendLine(" initContainers:"); + sb.AppendLine(" - name: dump"); + sb.AppendLine($" image: mongo:{mongo.MongoVersion}"); + sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]"); + sb.AppendLine($" args: [\"mongodump --uri=\\\"{mongoUri}\\\" --gzip --archive=/backup/dump.archive\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: MONGO_ADMIN_PASSWORD"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {mongo.Name}-admin-password"); + sb.AppendLine(" key: password"); + sb.AppendLine(" volumeMounts:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" mountPath: /backup"); + sb.AppendLine(" containers:"); + sb.AppendLine(" - name: upload"); + sb.AppendLine(" image: amazon/aws-cli"); + sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]"); + sb.AppendLine($" args: [\"aws s3 cp /backup/dump.archive {s3Base}/scheduled-$(date -u +%Y%m%dT%H%M%SZ).archive --endpoint-url {storageLink.Endpoint}\"]"); + sb.AppendLine(" env:"); + sb.AppendLine(" - name: AWS_ACCESS_KEY_ID"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: ACCESS_KEY"); + sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY"); + sb.AppendLine(" valueFrom:"); + sb.AppendLine(" secretKeyRef:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: SECRET_KEY"); + sb.AppendLine(" - name: AWS_DEFAULT_REGION"); + sb.AppendLine($" value: \"{storageLink.Region ?? "us-east-1"}\""); + sb.AppendLine(" volumeMounts:"); + sb.AppendLine(" - name: backup-data"); + sb.AppendLine(" mountPath: /backup"); + + return sb.ToString(); + } + + // ──────── JSON Parsing ──────── + + /// + /// Parses the MongoDBCommunity CRD JSON to extract phase and ready member count. + /// Community Operator status fields: status.phase, status.currentMongoDBMembers. + /// + private static void ParseClusterStatus(string json, MongoClusterDetail detail) + { + using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json); + System.Text.Json.JsonElement root = doc.RootElement; + + if (root.TryGetProperty("status", out System.Text.Json.JsonElement status)) + { + if (status.TryGetProperty("phase", out System.Text.Json.JsonElement phase)) + { + detail.Phase = phase.GetString() ?? "Unknown"; + } + + if (status.TryGetProperty("currentMongoDBMembers", out System.Text.Json.JsonElement members)) + { + detail.ReadyMembers = members.GetInt32(); + } + + if (status.TryGetProperty("message", out System.Text.Json.JsonElement messageEl)) + { + detail.Message = messageEl.GetString(); + } + + // Fall back to the most recent condition message if no top-level message. + if (string.IsNullOrEmpty(detail.Message) + && status.TryGetProperty("conditions", out System.Text.Json.JsonElement conditions) + && conditions.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (System.Text.Json.JsonElement condition in conditions.EnumerateArray()) + { + if (condition.TryGetProperty("message", out System.Text.Json.JsonElement condMsg)) + { + string? msg = condMsg.GetString(); + if (!string.IsNullOrEmpty(msg)) + { + detail.Message = msg; + break; + } + } + } + } + } + + // Derive the endpoint from the resource metadata name, which is stored on the cluster record. + if (root.TryGetProperty("metadata", out System.Text.Json.JsonElement metadata) + && metadata.TryGetProperty("name", out System.Text.Json.JsonElement nameEl) + && metadata.TryGetProperty("namespace", out System.Text.Json.JsonElement nsEl)) + { + detail.Endpoint = $"{nameEl.GetString()}-svc.{nsEl.GetString()}.svc.cluster.local:27017"; + } + } + + /// + /// Parses a kubectl "get pods -o json" response into a list of MongoPodInfo. + /// + private static List ParsePodList(string json, string clusterName) + { + List pods = []; + using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json); + System.Text.Json.JsonElement root = doc.RootElement; + + if (!root.TryGetProperty("items", out System.Text.Json.JsonElement items)) + { + return pods; + } + + string prefix = clusterName + "-"; + + foreach (System.Text.Json.JsonElement item in items.EnumerateArray()) + { + string podName = item.GetProperty("metadata").GetProperty("name").GetString() ?? ""; + + // Only include StatefulSet member pods: {clusterName}-0, {clusterName}-1, ... + // Skip Jobs, init containers, and any other pods in the namespace. + if (!podName.StartsWith(prefix)) continue; + string ordinal = podName[prefix.Length..]; + if (ordinal.Length == 0 || !ordinal.All(char.IsDigit)) continue; + string podStatus = "Unknown"; + bool ready = false; + string? node = null; + DateTime? startTime = null; + int restarts = 0; + + if (item.TryGetProperty("spec", out System.Text.Json.JsonElement spec) + && spec.TryGetProperty("nodeName", out System.Text.Json.JsonElement nodeName)) + { + node = nodeName.GetString(); + } + + if (item.TryGetProperty("status", out System.Text.Json.JsonElement statusEl)) + { + if (statusEl.TryGetProperty("phase", out System.Text.Json.JsonElement phaseEl)) + { + podStatus = phaseEl.GetString() ?? "Unknown"; + } + + if (statusEl.TryGetProperty("startTime", out System.Text.Json.JsonElement startEl)) + { + if (DateTime.TryParse(startEl.GetString(), null, + System.Globalization.DateTimeStyles.RoundtripKind, out DateTime parsed)) + { + startTime = parsed; + } + } + + if (statusEl.TryGetProperty("containerStatuses", out System.Text.Json.JsonElement containers)) + { + foreach (System.Text.Json.JsonElement container in containers.EnumerateArray()) + { + if (container.TryGetProperty("restartCount", out System.Text.Json.JsonElement rc)) + { + restarts += rc.GetInt32(); + } + + if (container.TryGetProperty("ready", out System.Text.Json.JsonElement readyEl)) + { + ready = ready || readyEl.GetBoolean(); + } + } + } + } + + pods.Add(new MongoPodInfo + { + Name = podName, + Status = podStatus, + Ready = ready, + Node = node, + StartTime = startTime, + Restarts = restarts + }); + } + + return pods; + } +} + +/// +/// Detail view model for a managed MongoDB cluster. +/// +public class MongoClusterDetail +{ + public required MongoCluster Cluster { get; set; } + public string Phase { get; set; } = "Unknown"; + public string? Message { get; set; } + public int ReadyMembers { get; set; } + public string? Endpoint { get; set; } + public List Pods { get; set; } = []; + public List Backups { get; set; } = []; +} + +/// +/// Pod information for a MongoDB cluster member. +/// +public class MongoPodInfo +{ + public required string Name { get; set; } + public string Status { get; set; } = "Unknown"; + public bool Ready { get; set; } + public string? Node { get; set; } + public DateTime? StartTime { get; set; } + public int Restarts { get; set; } +} diff --git a/src/EntKube.Web/Services/OpenStackS3Service.cs b/src/EntKube.Web/Services/OpenStackS3Service.cs index 68fd399..aa38337 100644 --- a/src/EntKube.Web/Services/OpenStackS3Service.cs +++ b/src/EntKube.Web/Services/OpenStackS3Service.cs @@ -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). /// - 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); + } + + /// + /// 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. + /// + public async Task> 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 ?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(); } /// @@ -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 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. + } } /// @@ -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 /// 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, diff --git a/src/EntKube.Web/Services/StorageService.cs b/src/EntKube.Web/Services/StorageService.cs index 38040cd..f412a80 100644 --- a/src/EntKube.Web/Services/StorageService.cs +++ b/src/EntKube.Web/Services/StorageService.cs @@ -392,10 +392,36 @@ public class StorageService(IDbContextFactory 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); } /// @@ -407,12 +433,20 @@ public class StorageService(IDbContextFactory 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. diff --git a/src/EntKube.Web/Services/VaultService.cs b/src/EntKube.Web/Services/VaultService.cs index a9e45ce..9de6cf7 100644 --- a/src/EntKube.Web/Services/VaultService.cs +++ b/src/EntKube.Web/Services/VaultService.cs @@ -144,10 +144,13 @@ public class VaultService(IDbContextFactory dbFactory, Vau // --- Component Secrets --- /// - /// 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. /// public async Task 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 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 dbFactory, Vau Name = name, EncryptedValue = ciphertext, Nonce = nonce, - ComponentId = componentId + ComponentId = componentId, + SyncToKubernetes = k8sSecretName is not null, + KubernetesSecretName = k8sSecretName, + KubernetesNamespace = k8sNamespace }; db.Set().Add(secret); @@ -439,6 +451,144 @@ public class VaultService(IDbContextFactory dbFactory, Vau .ToListAsync(ct); } + /// + /// Returns the decrypted password for a CNPG database. Used when another + /// service (e.g. Keycloak) needs to read the DB password to propagate it. + /// + public async Task GetCnpgDatabasePasswordAsync( + Guid tenantId, Guid cnpgDatabaseId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set() + .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); + } + + /// + /// Creates or updates a vault secret scoped to a MongoDB database. + /// Mirrors SetCnpgDatabaseSecretAsync but uses MongoDatabaseId. + /// + public async Task 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() + .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().Add(secret); + await db.SaveChangesAsync(ct); + return secret; + } + + /// + /// 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. + /// + public async Task 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() + .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().Add(secret); + await db.SaveChangesAsync(ct); + return secret; + } + + /// + /// Decrypts and returns the value of a secret scoped to a managed MongoDB cluster. + /// Returns null if no matching secret is found. + /// + public async Task GetMongoClusterSecretValueAsync( + Guid tenantId, Guid mongoClusterId, string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set() + .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 --- /// diff --git a/src/EntKube.Web/Services/YamlFormMerger.cs b/src/EntKube.Web/Services/YamlFormMerger.cs index 3094e26..b6b1a0f 100644 --- a/src/EntKube.Web/Services/YamlFormMerger.cs +++ b/src/EntKube.Web/Services/YamlFormMerger.cs @@ -55,6 +55,35 @@ public class ComponentFormField /// Defaults to the field Key if not specified. /// public string? SecretName { get; init; } + + /// + /// When set, this field is only shown when the sibling field with this key + /// has the value specified in . + /// Used for conditional fields (e.g. show cert/key inputs only when TLS mode is Manual). + /// + public string? DependsOnKey { get; init; } + + /// The value that must equal for this field to be visible. + public string? DependsOnValue { get; init; } + + /// + /// 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. + /// + public string? KubernetesSecretName { get; init; } + + /// + /// Namespace for the auto-synced Kubernetes Secret. Defaults to the component's namespace. + /// + public string? KubernetesSecretNamespace { get; init; } + + /// + /// 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. + /// + public string? SubchartDefaultValues { get; init; } } /// @@ -66,7 +95,9 @@ public enum FormFieldType Number, Password, Toggle, - Select + Select, + CnpgDatabase, + ClusterIssuer } /// @@ -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() - .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) { diff --git a/tests/EntKube.Web.Tests/CnpgServiceTests.cs b/tests/EntKube.Web.Tests/CnpgServiceTests.cs index 6fc0245..1081555 100644 --- a/tests/EntKube.Web.Tests/CnpgServiceTests.cs +++ b/tests/EntKube.Web.Tests/CnpgServiceTests.cs @@ -826,10 +826,10 @@ public class CnpgServiceTests : IDisposable (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); - string? appliedManifest = null; + List appliedManifests = []; k8sFactory.Setup(f => f.ApplyManifestAsync( It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((manifest, _, _) => appliedManifest = manifest) + .Callback((manifest, _, _) => appliedManifests.Add(manifest)) .Returns(Task.CompletedTask); // Act @@ -838,9 +838,113 @@ public class CnpgServiceTests : IDisposable tenant.Id, cluster.Id, "sched-test", "databases", 3, "10Gi", storageLink.Id, "0 0 2 * * *"); - // Assert — a ScheduledBackup resource should be in the manifest. + // Assert — ObjectStore and ScheduledBackup are applied in separate calls. - appliedManifest.Should().Contain("ScheduledBackup"); - appliedManifest.Should().Contain("0 0 2 * * *"); + string allManifests = string.Join("\n", appliedManifests); + appliedManifests.Should().Contain(m => m.Contains("ObjectStore")); + appliedManifests.Should().Contain(m => m.Contains("ScheduledBackup")); + appliedManifests.Should().Contain(m => m.Contains("0 0 2 * * *")); + appliedManifests.Should().Contain(m => m.Contains("immediate: true")); + } + + // ──────── GetDatabaseCredentialsAsync ──────── + + [Fact] + public async Task GetDatabaseCredentialsAsync_ReturnsDecryptedCredentials() + { + // Arrange — create a cluster and database so the vault has credentials. + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "creds-cluster", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "10Gi", + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + k8sFactory.Setup(f => f.ExecuteSqlAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + CnpgDatabase database = await sut.CreateDatabaseAsync(tenant.Id, cnpg.Id, "webapp"); + + // Act — retrieve the credentials from the vault. + + Dictionary credentials = await sut.GetDatabaseCredentialsAsync( + tenant.Id, database.Id); + + // Assert — all connection fields should be present with correct values. + + credentials.Should().ContainKey("HOST") + .WhoseValue.Should().Be("creds-cluster-rw.databases.svc.cluster.local"); + credentials.Should().ContainKey("PORT").WhoseValue.Should().Be("5432"); + credentials.Should().ContainKey("DATABASE").WhoseValue.Should().Be("webapp"); + credentials.Should().ContainKey("USERNAME").WhoseValue.Should().Be("webapp_owner"); + credentials.Should().ContainKey("PASSWORD"); + credentials["PASSWORD"].Should().HaveLength(32); + } + + // ──────── SyncDatabaseCredentialsToK8sAsync ──────── + + [Fact] + public async Task SyncDatabaseCredentialsToK8sAsync_AppliesK8sSecretManifest() + { + // Arrange — create a cluster and database with vault credentials. + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "sync-cluster", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "10Gi", + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + k8sFactory.Setup(f => f.ExecuteSqlAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + string? appliedManifest = null; + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((manifest, _, _) => appliedManifest = manifest) + .Returns(Task.CompletedTask); + + CnpgDatabase database = await sut.CreateDatabaseAsync(tenant.Id, cnpg.Id, "syncdb"); + + // Act — push the credentials to Kubernetes. + + await sut.SyncDatabaseCredentialsToK8sAsync(tenant.Id, cnpg.Id, database.Id); + + // Assert — a K8s Secret manifest with all credential fields should be applied. + + appliedManifest.Should().NotBeNull(); + appliedManifest.Should().Contain("kind: Secret"); + appliedManifest.Should().Contain("name: sync-cluster-syncdb-credentials"); + appliedManifest.Should().Contain("namespace: databases"); + appliedManifest.Should().Contain("HOST:"); + appliedManifest.Should().Contain("PORT:"); + appliedManifest.Should().Contain("DATABASE:"); + appliedManifest.Should().Contain("USERNAME:"); + appliedManifest.Should().Contain("PASSWORD:"); } } diff --git a/tests/EntKube.Web.Tests/MongoServiceTests.cs b/tests/EntKube.Web.Tests/MongoServiceTests.cs new file mode 100644 index 0000000..8cfd6d5 --- /dev/null +++ b/tests/EntKube.Web.Tests/MongoServiceTests.cs @@ -0,0 +1,379 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace EntKube.Web.Tests; + +/// +/// Tests for MongoService — the orchestration layer for managed MongoDB Community Operator clusters. +/// Covers cluster creation, deletion, upgrade, backup, restore, and database management. +/// +/// The Kubernetes API interactions are mocked via IKubernetesClientFactory. +/// Database state management and vault secret creation are tested against +/// a real SQLite in-memory database to verify the full orchestration flow. +/// +public class MongoServiceTests : IDisposable +{ + private static readonly byte[] TestRootKey = Convert.FromBase64String( + "dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg="); + + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly VaultService vaultService; + private readonly Mock k8sFactory; + private readonly MongoService sut; + + public MongoServiceTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + VaultEncryptionService encryption = new(TestRootKey); + vaultService = new VaultService(dbFactory, encryption); + k8sFactory = new Mock(); + sut = new MongoService(dbFactory, vaultService, k8sFactory.Object); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ──────── Helpers ──────── + + private async Task<(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink)> SeedTenantWithClusterAsync() + { + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestCo", Slug = "testco" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "Production" + }; + + db.Set().Add(env); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "prod-cluster", + ApiServerUrl = "https://k8s.example.com", + Kubeconfig = "apiVersion: v1\nkind: Config\nclusters: []" + }; + + db.KubernetesClusters.Add(cluster); + + // Install the MongoDB Community Operator on this cluster. + + ClusterComponent mongoOperator = new() + { + Id = Guid.NewGuid(), + ClusterId = cluster.Id, + Name = "mongodb-community-operator", + ComponentType = "helm", + Status = ComponentStatus.Installed, + Namespace = "mongodb-operator" + }; + + db.ClusterComponents.Add(mongoOperator); + + // Create a storage link for backups. + + StorageLink storageLink = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.CleuraS3, + Name = "Backup Bucket", + Endpoint = "https://s3-kna1.citycloud.com", + BucketName = "mongo-backups", + Region = "Kna1" + }; + + db.StorageLinks.Add(storageLink); + await db.SaveChangesAsync(); + + // Initialize vault and store S3 credentials. + + await vaultService.InitializeVaultAsync(tenant.Id); + await vaultService.SetStorageLinkSecretAsync(tenant.Id, storageLink.Id, "ACCESS_KEY", "test-access-key"); + await vaultService.SetStorageLinkSecretAsync(tenant.Id, storageLink.Id, "SECRET_KEY", "test-secret-key"); + + return (tenant, cluster, storageLink); + } + + // ──────── CreateClusterAsync ──────── + + [Fact] + public async Task CreateClusterAsync_ValidInput_CreatesDbRecordAndReturnsCluster() + { + // Arrange — a tenant with a cluster and MongoDB Community Operator installed. + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act — create a new managed MongoDB cluster. + + MongoCluster result = await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "my-mongo", "databases", 3, "10Gi", + storageLink.Id, "0 2 * * *"); + + // Assert — the cluster record should exist in the database. + + result.Should().NotBeNull(); + result.Name.Should().Be("my-mongo"); + result.Namespace.Should().Be("databases"); + result.Members.Should().Be(3); + result.StorageSize.Should().Be("10Gi"); + result.StorageLinkId.Should().Be(storageLink.Id); + result.BackupSchedule.Should().Be("0 2 * * *"); + result.Status.Should().Be(MongoClusterStatus.Creating); + + MongoCluster? persisted = await db.MongoClusters.FindAsync(result.Id); + persisted.Should().NotBeNull(); + } + + [Fact] + public async Task CreateClusterAsync_WithoutBackup_CreatesClusterWithNoStorageLink() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act — create without backup configuration. + + MongoCluster result = await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "standalone-mongo", "default", 1, "5Gi", + storageLinkId: null, backupSchedule: null); + + // Assert + + result.StorageLinkId.Should().BeNull(); + result.BackupSchedule.Should().BeNull(); + } + + [Fact] + public async Task CreateClusterAsync_NoMongoOperator_ThrowsInvalidOperation() + { + // Arrange — tenant with a cluster but NO MongoDB Community Operator. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "NoPercona", Slug = "no-percona" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Dev" }; + db.Set().Add(env); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "dev-cluster", + ApiServerUrl = "https://k8s.dev.example.com", + Kubeconfig = "fake" + }; + + db.KubernetesClusters.Add(cluster); + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.CreateClusterAsync( + tenant.Id, cluster.Id, "mongo", "default", 1, "5Gi", null, null); + + await act.Should().ThrowAsync() + .WithMessage("*MongoDB Community Operator*not installed*"); + } + + [Fact] + public async Task CreateClusterAsync_AppliesManifestToKubernetes() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + List appliedManifests = []; + string? appliedKubeconfig = null; + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((manifest, kubeconfig, _) => + { + appliedManifests.Add(manifest); + appliedKubeconfig = kubeconfig; + }) + .Returns(Task.CompletedTask); + + // Act + + await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "backup-mongo", "databases", 3, "20Gi", + storageLink.Id, "0 2 * * *"); + + // Assert — verify the manifests contain expected Community Operator configuration. + + string allManifests = string.Join("\n", appliedManifests); + allManifests.Should().Contain("kind: MongoDBCommunity"); + allManifests.Should().Contain("mongodbcommunity.mongodb.com/v1"); + allManifests.Should().Contain("name: backup-mongo"); + allManifests.Should().Contain("namespace: databases"); + appliedKubeconfig.Should().NotBeNull(); + } + + // ──────── DeleteClusterAsync ──────── + + [Fact] + public async Task DeleteClusterAsync_RemovesFromDbAndKubernetes() + { + // Arrange — create a cluster first. + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + k8sFactory.Setup(f => f.DeleteManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + MongoCluster created = await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "delete-me", "databases", 1, "5Gi", null, null); + + // Act + + await sut.DeleteClusterAsync(tenant.Id, created.Id); + + // Assert — should be gone from the database. + + MongoCluster? deleted = await db.MongoClusters.FindAsync(created.Id); + deleted.Should().BeNull(); + } + + // ──────── GetClustersAsync ──────── + + [Fact] + public async Task GetClustersAsync_ReturnsOnlyTenantClusters() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + await sut.CreateClusterAsync(tenant.Id, cluster.Id, "mongo-1", "ns1", 3, "10Gi", null, null); + await sut.CreateClusterAsync(tenant.Id, cluster.Id, "mongo-2", "ns2", 1, "5Gi", null, null); + + // Act + + List result = await sut.GetClustersAsync(tenant.Id); + + // Assert + + result.Should().HaveCount(2); + result.Select(c => c.Name).Should().Contain("mongo-1").And.Contain("mongo-2"); + } + + // ──────── CreateDatabaseAsync ──────── + + [Fact] + public async Task CreateDatabaseAsync_CreatesDbRecordAndExecutesMongo() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + k8sFactory.Setup(f => f.ExecuteMongoAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + k8sFactory.Setup(f => f.GetSecretValueAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync("test-admin-password"); + + MongoCluster mongoCluster = await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "db-test", "databases", 3, "10Gi", null, null); + + // Act + + MongoDatabase result = await sut.CreateDatabaseAsync(tenant.Id, mongoCluster.Id, "myapp"); + + // Assert + + result.Should().NotBeNull(); + result.Name.Should().Be("myapp"); + result.Status.Should().Be(MongoDatabaseStatus.Ready); + + k8sFactory.Verify(f => f.ExecuteMongoAsync( + "db-test", "databases", It.Is(s => s.Contains("myapp")), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ──────── BackupAsync ──────── + + [Fact] + public async Task BackupAsync_CreatesBackupRecordAndAppliesManifest() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + MongoCluster mongoCluster = await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "backup-test", "databases", 3, "10Gi", + storageLink.Id, "0 2 * * *"); + + // Act + + MongoBackup result = await sut.BackupAsync(tenant.Id, mongoCluster.Id); + + // Assert + + result.Should().NotBeNull(); + result.Type.Should().Be(MongoBackupType.OnDemand); + result.Status.Should().Be(MongoBackupStatus.Running); + + // Verify a backup Job manifest was applied. + + k8sFactory.Verify(f => f.ApplyManifestAsync( + It.Is(m => m.Contains("kind: Job") && m.Contains("mongodump")), + It.IsAny(), It.IsAny()), Times.AtLeastOnce); + } +}