blazor.web.js issues
This commit is contained in:
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# Copy this file to .env and fill in the values before running docker compose.
|
||||
|
||||
# --- Domain / TLS ---
|
||||
# Public domain pointing at this server. Caddy will obtain a Let's Encrypt
|
||||
# certificate automatically on first start. Port 80 and 443 must be reachable.
|
||||
DOMAIN=entkube.example.com
|
||||
|
||||
# Email used for Let's Encrypt account registration and expiry notices.
|
||||
ACME_EMAIL=admin@example.com
|
||||
|
||||
# --- Registry ---
|
||||
# Registry to push/pull the entkube image.
|
||||
# Authenticate before running compose: docker login ${REGISTRY} -u ${REGISTRY_USERNAME} -p ${REGISTRY_PASSWORD}
|
||||
REGISTRY=entit.azurecr.io
|
||||
IMAGE_TAG=latest
|
||||
REGISTRY_USERNAME=<acr-username-or-service-principal-id>
|
||||
REGISTRY_PASSWORD=<acr-password-or-service-principal-secret>
|
||||
|
||||
# --- Postgres ---
|
||||
# Password used by both the postgres service and the app connection string.
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
# --- Vault encryption ---
|
||||
# 32-byte base64 key for secret encryption.
|
||||
# Generate with: openssl rand -base64 32
|
||||
VAULT__ROOTKEY=REPLACE_WITH_BASE64_32_BYTE_KEY
|
||||
69
.github/workflows/deploy.yml
vendored
Normal file
69
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: entit.azurecr.io
|
||||
IMAGE: entit.azurecr.io/entkube
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and push image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ACR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=sha,prefix=,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
name: Deploy to server
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Pull and restart via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd ${{ secrets.COMPOSE_DIR }}
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ secrets.REGISTRY_HOST }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
docker image prune -f
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -423,5 +423,8 @@ FodyWeavers.xsd
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Local secrets — never commit the real .env, only .env.example
|
||||
.env
|
||||
|
||||
# Local reference infrastructure (not part of the project)
|
||||
CMKS - Infra/
|
||||
|
||||
7
Caddyfile
Normal file
7
Caddyfile
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
email {env.ACME_EMAIL}
|
||||
}
|
||||
|
||||
{env.DOMAIN} {
|
||||
reverse_proxy entkube:8080
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.101 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# wasm-tools provides Microsoft.AspNetCore.App.Internal.Assets, which contains
|
||||
# blazor.web.js, blazor.server.js, and the other Blazor framework JS files.
|
||||
# Without this workload the publish output silently omits those files.
|
||||
RUN dotnet workload install wasm-tools
|
||||
|
||||
COPY Directory.Build.props ./
|
||||
COPY src/EntKube.Web/EntKube.Web.csproj src/EntKube.Web/
|
||||
COPY src/EntKube.Web.Client/EntKube.Web.Client.csproj src/EntKube.Web.Client/
|
||||
@@ -22,7 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Run as non-root
|
||||
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
|
||||
RUN groupadd --system appgroup && useradd --system --gid appgroup --no-create-home appuser
|
||||
RUN mkdir -p /app/Data && chown appuser:appgroup /app/Data
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
53
README.md
53
README.md
@@ -42,6 +42,59 @@ EntKube/
|
||||
└── EntKube.Client/ # WebAssembly client project
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
### Build and push
|
||||
|
||||
> **Apple Silicon (M-series) Mac:** servers are typically `linux/amd64`. Use `docker buildx` to cross-compile and push in one step — the `--push` flag is required because multi-platform images cannot be loaded into the local Docker daemon.
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
docker buildx create --use --name multiarch
|
||||
|
||||
# Build for amd64 and push directly to the registry
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-t entit.azurecr.io/entkube:latest \
|
||||
--push .
|
||||
|
||||
# Tag a versioned release
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-t entit.azurecr.io/entkube:latest \
|
||||
-t entit.azurecr.io/entkube:1.0.0 \
|
||||
--push .
|
||||
```
|
||||
|
||||
Replace `entit.azurecr.io/entkube` with your actual registry and image path.
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
Copy `.env.example` to `.env` and fill in the required values, then authenticate with the registry and start the stack:
|
||||
|
||||
```bash
|
||||
# Authenticate with the container registry (once per machine / token expiry)
|
||||
docker login entit.azurecr.io -u $REGISTRY_USERNAME -p $REGISTRY_PASSWORD
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:8080`.
|
||||
|
||||
> **Data safety** — `docker compose down` does **not** delete the database volume.
|
||||
> Only `docker compose down -v` removes volumes.
|
||||
|
||||
### Required configuration
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DOMAIN` | Public domain pointing at the server (e.g. `entkube.example.com`). |
|
||||
| `ACME_EMAIL` | Email for Let's Encrypt account and expiry notices. |
|
||||
| `Vault__RootKey` | 32-byte base64 key for secret encryption. Generate with `openssl rand -base64 32`. |
|
||||
| `POSTGRES_PASSWORD` | Password for the Postgres database (set in `.env`). |
|
||||
|
||||
### TLS / HTTPS
|
||||
|
||||
Caddy is the reverse proxy and handles TLS automatically. On first start it obtains a Let's Encrypt certificate for `DOMAIN` and renews it automatically before it expires. Ports **80** and **443** must be open and your DNS must point at the server before starting the stack.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
environment:
|
||||
DOMAIN: "${DOMAIN}"
|
||||
ACME_EMAIL: "${ACME_EMAIL}"
|
||||
depends_on:
|
||||
- entkube
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:17
|
||||
environment:
|
||||
@@ -16,12 +33,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
entkube:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: entkube:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
image: ${REGISTRY:-entit.azurecr.io}/entkube:${IMAGE_TAG:-latest}
|
||||
volumes:
|
||||
- entkube-data:/app/Data
|
||||
depends_on:
|
||||
@@ -33,13 +45,21 @@ services:
|
||||
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=entkube;Username=entkube;Password=${POSTGRES_PASSWORD:-changeme}"
|
||||
|
||||
# --- Vault encryption ---
|
||||
# Required. Generate with: openssl rand -base64 32
|
||||
Vault__RootKey: "REPLACE_WITH_BASE64_32_BYTE_KEY"
|
||||
# Required — set VAULT__ROOTKEY in your .env file (openssl rand -base64 32)
|
||||
Vault__RootKey: "${VAULT__ROOTKEY}"
|
||||
|
||||
# --- Reverse proxy ---
|
||||
# Tells ASP.NET Core to trust X-Forwarded-For / X-Forwarded-Proto from Caddy.
|
||||
# Required for Blazor Server SignalR (WebSocket) to work correctly behind a proxy.
|
||||
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
|
||||
|
||||
# --- Auth ---
|
||||
# Set to false after the first admin account is created.
|
||||
Auth__AllowRegistration: "true"
|
||||
|
||||
# --- Temporary: log all HTTP requests so we can see 404s ---
|
||||
Logging__LogLevel__Microsoft__AspNetCore__Hosting__Diagnostics: "Information"
|
||||
|
||||
# --- Bootstrap ---
|
||||
# Optional. If set, this user is granted the Admin role on every startup
|
||||
# (no-op if already admin). Useful for recovering access.
|
||||
@@ -57,3 +77,5 @@ volumes:
|
||||
# Data is only deleted if you explicitly run "docker compose down -v".
|
||||
postgres-data:
|
||||
entkube-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
<!-- Skip Emscripten native relink — no WASM rendering is active on the server,
|
||||
so dotnet.wasm is never loaded. Use the pre-built runtime from the workload. -->
|
||||
<WasmBuildNative>false</WasmBuildNative>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveAuto
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Auth</PageTitle>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/counter"
|
||||
@rendermode InteractiveAuto
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Counter</PageTitle>
|
||||
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
|
||||
namespace EntKube.Web.Client;
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddAuthenticationStateDeserialization();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
}
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
@inject BackupService BackupService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>Backup & Restore</PageTitle>
|
||||
|
||||
@@ -41,44 +40,38 @@
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-1"><i class="bi bi-upload me-2 text-warning"></i>Restore from Backup</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
Upload a <code>.json.gz</code> backup file produced by this application.
|
||||
Upload a <code>.json.gz</code> or <code>.json</code> backup file produced by this application.
|
||||
Secrets are re-encrypted with the current server's root key on restore.
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<InputFile OnChange="OnFileSelected" accept=".gz" class="form-control" />
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="wipeCheck" @bind="wipeExisting" />
|
||||
<label class="form-check-label" for="wipeCheck">
|
||||
Wipe all existing data before restoring
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (wipeExisting)
|
||||
{
|
||||
<div class="alert alert-danger py-2 small mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>All current data will be permanently deleted</strong> before the
|
||||
backup is loaded. This cannot be undone. Only proceed on a fresh
|
||||
installation or when you intend a full replacement.
|
||||
<form action="/api/admin/restore" method="post" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<input type="file" name="bundle" class="form-control" required />
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-warning" @onclick="RunRestore"
|
||||
disabled="@(selectedFile is null || restoring)">
|
||||
@if (restoring)
|
||||
<div class="form-check mb-3">
|
||||
@* name+value on the checkbox so the browser submits "wipe=true" when it is checked *@
|
||||
<input class="form-check-input" type="checkbox" name="wipe" value="true" id="wipeCheck"
|
||||
@onchange="e => wipeExisting = (bool)(e.Value ?? false)" />
|
||||
<label class="form-check-label" for="wipeCheck">
|
||||
Wipe all existing data before restoring
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (wipeExisting)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
<span>Restoring…</span>
|
||||
<div class="alert alert-danger py-2 small mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>All current data will be permanently deleted</strong> before the
|
||||
backup is loaded. This cannot be undone. Only proceed on a fresh
|
||||
installation or when you intend a full replacement.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-upload me-1"></i>
|
||||
<span>Restore</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-upload me-1"></i>Restore
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,42 +93,15 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
private IBrowserFile? selectedFile;
|
||||
private bool wipeExisting;
|
||||
private bool restoring;
|
||||
private string? successMessage;
|
||||
private string? errorMessage;
|
||||
|
||||
private void OnFileSelected(InputFileChangeEventArgs e)
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
selectedFile = e.File;
|
||||
successMessage = null;
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
private async Task RunRestore()
|
||||
{
|
||||
if (selectedFile is null) return;
|
||||
|
||||
restoring = true;
|
||||
successMessage = null;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
// 50 MB ceiling — backups are gzip-compressed JSON so this is generous.
|
||||
await using Stream stream = selectedFile.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024);
|
||||
await BackupService.ImportAsync(stream, wipeExisting);
|
||||
successMessage = $"Restore completed successfully from '{selectedFile.Name}'.";
|
||||
selectedFile = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Restore failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
restoring = false;
|
||||
}
|
||||
var uri = new Uri(Nav.Uri);
|
||||
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
|
||||
if (query.TryGetValue("success", out var s)) successMessage = s;
|
||||
if (query.TryGetValue("error", out var e)) errorMessage = e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
@@ -11,34 +11,9 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
|
||||
table: "KeycloakRealms");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId",
|
||||
table: "VaultSecrets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "KeycloakConnections");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VaultSecrets_KeycloakConnectionId",
|
||||
table: "VaultSecrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KeycloakConnectionId",
|
||||
table: "VaultSecrets");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "KeycloakConnectionId",
|
||||
table: "KeycloakRealms",
|
||||
newName: "KeycloakComponentConfigId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_KeycloakRealms_KeycloakConnectionId_RealmName",
|
||||
table: "KeycloakRealms",
|
||||
newName: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName");
|
||||
// The migration that originally created KeycloakConnections and KeycloakRealms
|
||||
// was removed from history. This migration now creates both tables from scratch
|
||||
// using the final schema (post-rename), so fresh databases work correctly.
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "KeycloakComponentConfigs",
|
||||
@@ -75,6 +50,38 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "KeycloakRealms",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AccountTheme = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
KeycloakComponentConfigId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
LinkedAppId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
LoginTheme = table.Column<string>(type: "text", nullable: true),
|
||||
RealmName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_KeycloakRealms", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_KeycloakRealms_Apps_LinkedAppId",
|
||||
column: x => x.LinkedAppId,
|
||||
principalTable: "Apps",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~",
|
||||
column: x => x.KeycloakComponentConfigId,
|
||||
principalTable: "KeycloakComponentConfigs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakComponentConfigs_ClusterComponentId",
|
||||
table: "KeycloakComponentConfigs",
|
||||
@@ -90,111 +97,23 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
||||
table: "KeycloakComponentConfigs",
|
||||
column: "TenantId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~",
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakRealms_LinkedAppId",
|
||||
table: "KeycloakRealms",
|
||||
column: "KeycloakComponentConfigId",
|
||||
principalTable: "KeycloakComponentConfigs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
column: "LinkedAppId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName",
|
||||
table: "KeycloakRealms",
|
||||
columns: new[] { "KeycloakComponentConfigId", "RealmName" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~",
|
||||
table: "KeycloakRealms");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "KeycloakComponentConfigs");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "KeycloakComponentConfigId",
|
||||
table: "KeycloakRealms",
|
||||
newName: "KeycloakConnectionId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName",
|
||||
table: "KeycloakRealms",
|
||||
newName: "IX_KeycloakRealms_KeycloakConnectionId_RealmName");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "KeycloakConnectionId",
|
||||
table: "VaultSecrets",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "KeycloakConnections",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AdminPasswordSecretId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
KubernetesClusterId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AdminUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
AdminUsername = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_KeycloakConnections", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_KeycloakConnections_KubernetesClusters_KubernetesClusterId",
|
||||
column: x => x.KubernetesClusterId,
|
||||
principalTable: "KubernetesClusters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_KeycloakConnections_Tenants_TenantId",
|
||||
column: x => x.TenantId,
|
||||
principalTable: "Tenants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_KeycloakConnections_VaultSecrets_AdminPasswordSecretId",
|
||||
column: x => x.AdminPasswordSecretId,
|
||||
principalTable: "VaultSecrets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VaultSecrets_KeycloakConnectionId",
|
||||
table: "VaultSecrets",
|
||||
column: "KeycloakConnectionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakConnections_AdminPasswordSecretId",
|
||||
table: "KeycloakConnections",
|
||||
column: "AdminPasswordSecretId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakConnections_KubernetesClusterId",
|
||||
table: "KeycloakConnections",
|
||||
column: "KubernetesClusterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakConnections_TenantId",
|
||||
table: "KeycloakConnections",
|
||||
column: "TenantId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
|
||||
table: "KeycloakRealms",
|
||||
column: "KeycloakConnectionId",
|
||||
principalTable: "KeycloakConnections",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId",
|
||||
table: "VaultSecrets",
|
||||
column: "KeycloakConnectionId",
|
||||
principalTable: "KeycloakConnections",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
migrationBuilder.DropTable(name: "KeycloakRealms");
|
||||
migrationBuilder.DropTable(name: "KeycloakComponentConfigs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
@@ -11,51 +11,56 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||
table: "KeycloakBackups");
|
||||
// The migration that originally created KeycloakBackups was removed from history.
|
||||
// This migration now creates the table directly with the final schema (nullable StorageLinkId).
|
||||
migrationBuilder.CreateTable(
|
||||
name: "KeycloakBackups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
KeycloakRealmId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
ObjectKey = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
RealmName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
StorageLinkId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_KeycloakBackups", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_KeycloakBackups_KeycloakRealms_KeycloakRealmId",
|
||||
column: x => x.KeycloakRealmId,
|
||||
principalTable: "KeycloakRealms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||
column: x => x.StorageLinkId,
|
||||
principalTable: "StorageLinks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "StorageLinkId",
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakBackups_KeycloakRealmId",
|
||||
table: "KeycloakBackups",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
column: "KeycloakRealmId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakBackups_StorageLinkId",
|
||||
table: "KeycloakBackups",
|
||||
column: "StorageLinkId",
|
||||
principalTable: "StorageLinks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
column: "StorageLinkId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||
table: "KeycloakBackups");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "StorageLinkId",
|
||||
table: "KeycloakBackups",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||
table: "KeycloakBackups",
|
||||
column: "StorageLinkId",
|
||||
principalTable: "StorageLinks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
migrationBuilder.DropTable(name: "KeycloakBackups");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ public class Program
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents()
|
||||
.AddInteractiveWebAssemblyComponents()
|
||||
.AddAuthenticationStateSerialization();
|
||||
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
@@ -221,7 +220,6 @@ public class Program
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<EntKube.Web.Components.App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddInteractiveWebAssemblyRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(Client._Imports).Assembly);
|
||||
|
||||
// Add additional endpoints required by the Identity /Account Razor components.
|
||||
@@ -241,6 +239,43 @@ public class Program
|
||||
return Results.File(data, "application/gzip", filename);
|
||||
}).RequireAuthorization();
|
||||
|
||||
// Backup restore — accepts a multipart form upload of a .json.gz (or plain .json) bundle.
|
||||
app.MapPost("/api/admin/restore", [Microsoft.AspNetCore.Mvc.RequestSizeLimit(50 * 1024 * 1024)]
|
||||
async (HttpRequest request, BackupService backupService, HttpContext httpContext) =>
|
||||
{
|
||||
System.Security.Claims.ClaimsPrincipal user = httpContext.User;
|
||||
if (user.Identity?.IsAuthenticated != true || !user.IsInRole("Admin"))
|
||||
return Results.Forbid();
|
||||
|
||||
if (!request.HasFormContentType)
|
||||
return Results.Redirect("/admin/backup?error=Invalid+request+format.");
|
||||
|
||||
IFormFile? bundle = request.Form.Files["bundle"];
|
||||
if (bundle is null || bundle.Length == 0)
|
||||
return Results.Redirect("/admin/backup?error=No+file+uploaded.");
|
||||
|
||||
bool wipe = request.Form.TryGetValue("wipe", out Microsoft.Extensions.Primitives.StringValues wipeVal)
|
||||
&& wipeVal == "true";
|
||||
|
||||
try
|
||||
{
|
||||
await using Stream stream = bundle.OpenReadStream();
|
||||
await backupService.ImportAsync(stream, wipe);
|
||||
return Results.Redirect("/admin/backup?success=Restore+completed+successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Walk the full exception chain so EF inner exceptions (constraint violations, etc.) are visible.
|
||||
var msg = new System.Text.StringBuilder();
|
||||
for (Exception? e = ex; e != null; e = e.InnerException)
|
||||
{
|
||||
if (msg.Length > 0) msg.Append(" → ");
|
||||
msg.Append(e.Message);
|
||||
}
|
||||
return Results.Redirect($"/admin/backup?error={Uri.EscapeDataString(msg.ToString())}");
|
||||
}
|
||||
}).RequireAuthorization().DisableAntiforgery();
|
||||
|
||||
// Git webhook endpoint — receives push events from GitHub, Azure DevOps, etc.
|
||||
// Route: POST /api/git/webhook/{tenantSlug}
|
||||
// No authentication required (public endpoint); signature verification is
|
||||
@@ -263,30 +298,41 @@ public class Program
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to apply pending EF Core migrations, retrying up to 5 times with
|
||||
/// exponential backoff. This handles the common scenario in containerized
|
||||
/// deployments where the database container isn't ready when the app starts.
|
||||
/// Applies all pending EF Core migrations, retrying with exponential backoff.
|
||||
/// Throws on final failure so the container exits and the orchestrator restarts it
|
||||
/// rather than running with a broken schema.
|
||||
/// </summary>
|
||||
private static void MigrateWithRetry(DbContext db, ILogger logger)
|
||||
{
|
||||
int maxRetries = 5;
|
||||
int maxRetries = 10;
|
||||
int delayMs = 1000;
|
||||
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEnumerable<string> pending = db.Database.GetPendingMigrations().ToList();
|
||||
if (pending.Any())
|
||||
logger.LogInformation("Applying {Count} pending migration(s): {Migrations}",
|
||||
pending.Count(), string.Join(", ", pending));
|
||||
|
||||
db.Database.Migrate();
|
||||
logger.LogInformation("Database migrations applied successfully.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxRetries)
|
||||
{
|
||||
logger.LogWarning(ex, "Migration attempt {Attempt} failed. Retrying in {Delay}ms...", attempt, delayMs);
|
||||
logger.LogWarning(ex, "Migration attempt {Attempt}/{Max} failed. Retrying in {Delay}ms...",
|
||||
attempt, maxRetries, delayMs);
|
||||
Thread.Sleep(delayMs);
|
||||
delayMs *= 2;
|
||||
delayMs = Math.Min(delayMs * 2, 16_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Final attempt — let the exception propagate so the container exits cleanly
|
||||
// and the orchestrator restarts it rather than serving traffic with a bad schema.
|
||||
logger.LogCritical("All {Max} migration attempts failed. Terminating.", maxRetries);
|
||||
db.Database.Migrate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ public class BackupService(
|
||||
s.AppId, s.ComponentId, s.StorageLinkId, s.OpenStackConnectionId,
|
||||
s.CnpgClusterId, s.CnpgDatabaseId, s.MongoDatabaseId, s.MongoClusterId,
|
||||
s.RegisteredPostgresDatabaseId, s.RabbitMQClusterId,
|
||||
s.RedisClusterId, s.VpnRemoteEndpointId, s.GitRepositoryId, s.CustomerGitCredentialId,
|
||||
s.SyncToKubernetes, s.KubernetesClusterId, s.KubernetesSecretName, s.KubernetesNamespace,
|
||||
s.CreatedAt, s.UpdatedAt));
|
||||
}
|
||||
@@ -127,6 +128,14 @@ public class BackupService(
|
||||
RegisteredPostgresDatabases = await db.RegisteredPostgresDatabases.AsNoTracking().ToListAsync(),
|
||||
DatabaseBindings = await db.DatabaseBindings.AsNoTracking().ToListAsync(),
|
||||
MessagingBindings = await db.MessagingBindings.AsNoTracking().ToListAsync(),
|
||||
GitRepositories = await db.GitRepositories.AsNoTracking().ToListAsync(),
|
||||
GitKnownHosts = await db.GitKnownHosts.AsNoTracking().ToListAsync(),
|
||||
CustomerGitCredentials = await db.CustomerGitCredentials.AsNoTracking().ToListAsync(),
|
||||
CustomerGitRepoPolicies = await db.CustomerGitRepoPolicies.AsNoTracking().ToListAsync(),
|
||||
RedisClusters = await db.RedisClusters.AsNoTracking().ToListAsync(),
|
||||
VpnTunnels = await db.VpnTunnels.AsNoTracking().ToListAsync(),
|
||||
VpnLocalEndpoints = await db.VpnLocalEndpoints.AsNoTracking().ToListAsync(),
|
||||
VpnRemoteEndpoints = await db.VpnRemoteEndpoints.AsNoTracking().ToListAsync(),
|
||||
KeycloakComponentConfigs = await db.KeycloakComponentConfigs.AsNoTracking().ToListAsync(),
|
||||
KeycloakThemes = await db.KeycloakThemes.AsNoTracking().ToListAsync(),
|
||||
KeycloakRealms = await db.KeycloakRealms.AsNoTracking().ToListAsync(),
|
||||
@@ -161,14 +170,31 @@ public class BackupService(
|
||||
/// </summary>
|
||||
public async Task ImportAsync(Stream bundleStream, bool wipeExisting)
|
||||
{
|
||||
await using GZipStream gz = new(bundleStream, CompressionMode.Decompress);
|
||||
BackupBundle? bundle = await JsonSerializer.DeserializeAsync<BackupBundle>(gz, JsonOptions)
|
||||
// Buffer the stream so we can inspect the magic bytes without consuming them.
|
||||
// (Blazor's IBrowserFile stream is not seekable.)
|
||||
using MemoryStream ms = new();
|
||||
await bundleStream.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
|
||||
// Gzip magic: 0x1F 0x8B. Accept plain JSON too — macOS browsers/Archive Utility
|
||||
// sometimes auto-decompress .gz downloads, leaving the user with a plain .json file.
|
||||
bool isGzip = ms.Length >= 2 && ms.ReadByte() == 0x1F && ms.ReadByte() == 0x8B;
|
||||
ms.Position = 0;
|
||||
|
||||
await using Stream jsonStream = isGzip ? new GZipStream(ms, CompressionMode.Decompress) : ms;
|
||||
BackupBundle? bundle = await JsonSerializer.DeserializeAsync<BackupBundle>(jsonStream, JsonOptions)
|
||||
?? throw new InvalidDataException("Failed to deserialize backup bundle.");
|
||||
|
||||
if (bundle.Version != 1)
|
||||
throw new InvalidDataException($"Unsupported backup version: {bundle.Version}. Only version 1 is supported.");
|
||||
|
||||
await using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||
|
||||
if (!wipeExisting && await db.Roles.AnyAsync())
|
||||
throw new InvalidOperationException(
|
||||
"The target database already contains data. " +
|
||||
"Enable \"Wipe all existing data\" to overwrite it, or restore to a fresh installation.");
|
||||
|
||||
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
@@ -217,6 +243,11 @@ public class BackupService(
|
||||
await InsertEntities(db, db.Environments, bundle.Environments);
|
||||
await InsertEntities(db, db.Customers, bundle.Customers);
|
||||
await InsertEntities(db, db.CustomerAccesses, bundle.CustomerAccesses);
|
||||
|
||||
// Customer git credentials must exist before GitRepositories and VaultSecrets reference them.
|
||||
await InsertEntities(db, db.CustomerGitCredentials, bundle.CustomerGitCredentials);
|
||||
await InsertEntities(db, db.CustomerGitRepoPolicies, bundle.CustomerGitRepoPolicies);
|
||||
|
||||
await InsertEntities(db, db.Apps, bundle.Apps);
|
||||
await InsertEntities(db, db.AppEnvironments, bundle.AppEnvironments);
|
||||
|
||||
@@ -226,9 +257,36 @@ public class BackupService(
|
||||
await InsertEntities(db, db.OpenStackConnections, bundle.OpenStackConnections);
|
||||
await InsertEntities(db, db.KubernetesClusters, bundle.KubernetesClusters);
|
||||
await InsertEntities(db, db.ClusterComponents, bundle.ClusterComponents);
|
||||
|
||||
// Git repos, redis, and VPN depend on clusters/credentials already inserted above.
|
||||
await InsertEntities(db, db.GitKnownHosts, bundle.GitKnownHosts);
|
||||
|
||||
// Null out CustomerGitCredentialId on repos not covered by this bundle (older exports).
|
||||
var bundledCredIds = bundle.CustomerGitCredentials.Select(c => c.Id).ToHashSet();
|
||||
foreach (var r in bundle.GitRepositories.Where(r => r.CustomerGitCredentialId.HasValue && !bundledCredIds.Contains(r.CustomerGitCredentialId!.Value)))
|
||||
r.CustomerGitCredentialId = null;
|
||||
await InsertEntities(db, db.GitRepositories, bundle.GitRepositories);
|
||||
|
||||
await InsertEntities(db, db.RedisClusters, bundle.RedisClusters);
|
||||
await InsertEntities(db, db.VpnTunnels, bundle.VpnTunnels);
|
||||
await InsertEntities(db, db.VpnLocalEndpoints, bundle.VpnLocalEndpoints);
|
||||
await InsertEntities(db, db.VpnRemoteEndpoints, bundle.VpnRemoteEndpoints);
|
||||
|
||||
await InsertEntities(db, db.ExternalRoutes, bundle.ExternalRoutes);
|
||||
await InsertEntities(db, db.StorageLinks, bundle.StorageLinks);
|
||||
|
||||
// Build sets of IDs that are actually present in this bundle so we can null out
|
||||
// dangling FK references that arise from older bundles or deleted source records.
|
||||
var bundledRepoIds = bundle.GitRepositories.Select(r => r.Id).ToHashSet();
|
||||
var bundledRedisIds = bundle.RedisClusters.Select(r => r.Id).ToHashSet();
|
||||
var bundledVpnEndIds = bundle.VpnRemoteEndpoints.Select(e => e.Id).ToHashSet();
|
||||
|
||||
foreach (var d in bundle.AppDeployments)
|
||||
{
|
||||
if (d.GitRepositoryId.HasValue && !bundledRepoIds.Contains(d.GitRepositoryId.Value))
|
||||
d.GitRepositoryId = null;
|
||||
}
|
||||
|
||||
// Deployments — apps and clusters must exist first (RESTRICT FKs).
|
||||
await InsertEntities(db, db.AppDeployments, bundle.AppDeployments);
|
||||
await InsertEntities(db, db.DeploymentManifests, bundle.DeploymentManifests);
|
||||
@@ -259,6 +317,22 @@ public class BackupService(
|
||||
await InsertEntities(db, db.SlaTargets, bundle.SlaTargets);
|
||||
await InsertEntities(db, db.MaintenanceWindows, bundle.MaintenanceWindows);
|
||||
|
||||
// Null out VaultSecret FKs that point to entities not present in this bundle
|
||||
// (backwards-compatible with bundles exported before these entity types were added).
|
||||
for (int i = 0; i < bundle.VaultSecrets.Count; i++)
|
||||
{
|
||||
VaultSecretRecord sr = bundle.VaultSecrets[i];
|
||||
if (sr.GitRepositoryId.HasValue && !bundledRepoIds.Contains(sr.GitRepositoryId.Value))
|
||||
sr = sr with { GitRepositoryId = null };
|
||||
if (sr.RedisClusterId.HasValue && !bundledRedisIds.Contains(sr.RedisClusterId.Value))
|
||||
sr = sr with { RedisClusterId = null };
|
||||
if (sr.VpnRemoteEndpointId.HasValue && !bundledVpnEndIds.Contains(sr.VpnRemoteEndpointId.Value))
|
||||
sr = sr with { VpnRemoteEndpointId = null };
|
||||
if (sr.CustomerGitCredentialId.HasValue && !bundledCredIds.Contains(sr.CustomerGitCredentialId.Value))
|
||||
sr = sr with { CustomerGitCredentialId = null };
|
||||
bundle.VaultSecrets[i] = sr;
|
||||
}
|
||||
|
||||
// Secrets — generate a fresh DEK for every vault and re-encrypt all
|
||||
// secrets with it. The new DEK is sealed with the new server's root key.
|
||||
Dictionary<Guid, byte[]> newDekByVaultId = [];
|
||||
@@ -298,6 +372,10 @@ public class BackupService(
|
||||
CnpgDatabaseId = sr.CnpgDatabaseId, MongoDatabaseId = sr.MongoDatabaseId,
|
||||
MongoClusterId = sr.MongoClusterId, RegisteredPostgresDatabaseId = sr.RegisteredPostgresDatabaseId,
|
||||
RabbitMQClusterId = sr.RabbitMQClusterId,
|
||||
RedisClusterId = sr.RedisClusterId,
|
||||
VpnRemoteEndpointId = sr.VpnRemoteEndpointId,
|
||||
GitRepositoryId = sr.GitRepositoryId,
|
||||
CustomerGitCredentialId = sr.CustomerGitCredentialId,
|
||||
SyncToKubernetes = sr.SyncToKubernetes, KubernetesClusterId = sr.KubernetesClusterId,
|
||||
KubernetesSecretName = sr.KubernetesSecretName, KubernetesNamespace = sr.KubernetesNamespace,
|
||||
CreatedAt = sr.CreatedAt, UpdatedAt = sr.UpdatedAt,
|
||||
|
||||
Reference in New Issue
Block a user