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-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|
||||||
|
# Local secrets — never commit the real .env, only .env.example
|
||||||
|
.env
|
||||||
|
|
||||||
# Local reference infrastructure (not part of the project)
|
# Local reference infrastructure (not part of the project)
|
||||||
CMKS - Infra/
|
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
|
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 Directory.Build.props ./
|
||||||
COPY src/EntKube.Web/EntKube.Web.csproj src/EntKube.Web/
|
COPY src/EntKube.Web/EntKube.Web.csproj src/EntKube.Web/
|
||||||
COPY src/EntKube.Web.Client/EntKube.Web.Client.csproj src/EntKube.Web.Client/
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Run as non-root
|
# 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
|
RUN mkdir -p /app/Data && chown appuser:appgroup /app/Data
|
||||||
|
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -42,6 +42,59 @@ EntKube/
|
|||||||
└── EntKube.Client/ # WebAssembly client project
|
└── 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
|
## License
|
||||||
|
|
||||||
See [LICENSE](LICENSE).
|
See [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
services:
|
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:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
environment:
|
environment:
|
||||||
@@ -16,12 +33,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
entkube:
|
entkube:
|
||||||
build:
|
image: ${REGISTRY:-entit.azurecr.io}/entkube:${IMAGE_TAG:-latest}
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: entkube:latest
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
volumes:
|
||||||
- entkube-data:/app/Data
|
- entkube-data:/app/Data
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -33,13 +45,21 @@ services:
|
|||||||
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=entkube;Username=entkube;Password=${POSTGRES_PASSWORD:-changeme}"
|
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=entkube;Username=entkube;Password=${POSTGRES_PASSWORD:-changeme}"
|
||||||
|
|
||||||
# --- Vault encryption ---
|
# --- Vault encryption ---
|
||||||
# Required. Generate with: openssl rand -base64 32
|
# Required — set VAULT__ROOTKEY in your .env file (openssl rand -base64 32)
|
||||||
Vault__RootKey: "REPLACE_WITH_BASE64_32_BYTE_KEY"
|
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 ---
|
# --- Auth ---
|
||||||
# Set to false after the first admin account is created.
|
# Set to false after the first admin account is created.
|
||||||
Auth__AllowRegistration: "true"
|
Auth__AllowRegistration: "true"
|
||||||
|
|
||||||
|
# --- Temporary: log all HTTP requests so we can see 404s ---
|
||||||
|
Logging__LogLevel__Microsoft__AspNetCore__Hosting__Diagnostics: "Information"
|
||||||
|
|
||||||
# --- Bootstrap ---
|
# --- Bootstrap ---
|
||||||
# Optional. If set, this user is granted the Admin role on every startup
|
# Optional. If set, this user is granted the Admin role on every startup
|
||||||
# (no-op if already admin). Useful for recovering access.
|
# (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".
|
# Data is only deleted if you explicitly run "docker compose down -v".
|
||||||
postgres-data:
|
postgres-data:
|
||||||
entkube-data:
|
entkube-data:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
|
<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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@rendermode InteractiveAuto
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<PageTitle>Auth</PageTitle>
|
<PageTitle>Auth</PageTitle>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@page "/counter"
|
@page "/counter"
|
||||||
@rendermode InteractiveAuto
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<PageTitle>Counter</PageTitle>
|
<PageTitle>Counter</PageTitle>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
|
||||||
namespace EntKube.Web.Client;
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
class Program
|
builder.Services.AddAuthorizationCore();
|
||||||
{
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
static async Task Main(string[] args)
|
|
||||||
{
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
|
||||||
|
|
||||||
builder.Services.AddAuthorizationCore();
|
await builder.Build().RunAsync();
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
|
||||||
builder.Services.AddAuthenticationStateDeserialization();
|
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@attribute [Authorize(Roles = "Admin")]
|
@attribute [Authorize(Roles = "Admin")]
|
||||||
|
|
||||||
@inject BackupService BackupService
|
@inject NavigationManager Nav
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
|
||||||
|
|
||||||
<PageTitle>Backup & Restore</PageTitle>
|
<PageTitle>Backup & Restore</PageTitle>
|
||||||
|
|
||||||
@@ -41,16 +40,19 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-1"><i class="bi bi-upload me-2 text-warning"></i>Restore from Backup</h5>
|
<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">
|
<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.
|
Secrets are re-encrypted with the current server's root key on restore.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<form action="/api/admin/restore" method="post" enctype="multipart/form-data">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<InputFile OnChange="OnFileSelected" accept=".gz" class="form-control" />
|
<input type="file" name="bundle" class="form-control" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check mb-3">
|
<div class="form-check mb-3">
|
||||||
<input class="form-check-input" type="checkbox" id="wipeCheck" @bind="wipeExisting" />
|
@* 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">
|
<label class="form-check-label" for="wipeCheck">
|
||||||
Wipe all existing data before restoring
|
Wipe all existing data before restoring
|
||||||
</label>
|
</label>
|
||||||
@@ -66,19 +68,10 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button class="btn btn-warning" @onclick="RunRestore"
|
<button type="submit" class="btn btn-warning">
|
||||||
disabled="@(selectedFile is null || restoring)">
|
<i class="bi bi-upload me-1"></i>Restore
|
||||||
@if (restoring)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
|
||||||
<span>Restoring…</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<i class="bi bi-upload me-1"></i>
|
|
||||||
<span>Restore</span>
|
|
||||||
}
|
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,42 +93,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private IBrowserFile? selectedFile;
|
|
||||||
private bool wipeExisting;
|
private bool wipeExisting;
|
||||||
private bool restoring;
|
|
||||||
private string? successMessage;
|
private string? successMessage;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
|
|
||||||
private void OnFileSelected(InputFileChangeEventArgs e)
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
selectedFile = e.File;
|
var uri = new Uri(Nav.Uri);
|
||||||
successMessage = null;
|
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
|
||||||
errorMessage = null;
|
if (query.TryGetValue("success", out var s)) successMessage = s;
|
||||||
}
|
if (query.TryGetValue("error", out var e)) errorMessage = e;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
@@ -11,34 +11,9 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropForeignKey(
|
// The migration that originally created KeycloakConnections and KeycloakRealms
|
||||||
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
|
// was removed from history. This migration now creates both tables from scratch
|
||||||
table: "KeycloakRealms");
|
// using the final schema (post-rename), so fresh databases work correctly.
|
||||||
|
|
||||||
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(
|
migrationBuilder.CreateTable(
|
||||||
name: "KeycloakComponentConfigs",
|
name: "KeycloakComponentConfigs",
|
||||||
@@ -75,6 +50,38 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
onDelete: ReferentialAction.Cascade);
|
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(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_KeycloakComponentConfigs_ClusterComponentId",
|
name: "IX_KeycloakComponentConfigs_ClusterComponentId",
|
||||||
table: "KeycloakComponentConfigs",
|
table: "KeycloakComponentConfigs",
|
||||||
@@ -90,111 +97,23 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
table: "KeycloakComponentConfigs",
|
table: "KeycloakComponentConfigs",
|
||||||
column: "TenantId");
|
column: "TenantId");
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
migrationBuilder.CreateIndex(
|
||||||
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~",
|
name: "IX_KeycloakRealms_LinkedAppId",
|
||||||
table: "KeycloakRealms",
|
table: "KeycloakRealms",
|
||||||
column: "KeycloakComponentConfigId",
|
column: "LinkedAppId");
|
||||||
principalTable: "KeycloakComponentConfigs",
|
|
||||||
principalColumn: "Id",
|
migrationBuilder.CreateIndex(
|
||||||
onDelete: ReferentialAction.Cascade);
|
name: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName",
|
||||||
|
table: "KeycloakRealms",
|
||||||
|
columns: new[] { "KeycloakComponentConfigId", "RealmName" },
|
||||||
|
unique: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropForeignKey(
|
migrationBuilder.DropTable(name: "KeycloakRealms");
|
||||||
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~",
|
migrationBuilder.DropTable(name: "KeycloakComponentConfigs");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
@@ -11,51 +11,56 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropForeignKey(
|
// 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",
|
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||||
table: "KeycloakBackups");
|
column: x => x.StorageLinkId,
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<Guid>(
|
|
||||||
name: "StorageLinkId",
|
|
||||||
table: "KeycloakBackups",
|
|
||||||
type: "uuid",
|
|
||||||
nullable: true,
|
|
||||||
oldClrType: typeof(Guid),
|
|
||||||
oldType: "uuid");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
|
||||||
table: "KeycloakBackups",
|
|
||||||
column: "StorageLinkId",
|
|
||||||
principalTable: "StorageLinks",
|
principalTable: "StorageLinks",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.SetNull);
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KeycloakBackups_KeycloakRealmId",
|
||||||
|
table: "KeycloakBackups",
|
||||||
|
column: "KeycloakRealmId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KeycloakBackups_StorageLinkId",
|
||||||
|
table: "KeycloakBackups",
|
||||||
|
column: "StorageLinkId");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropForeignKey(
|
migrationBuilder.DropTable(name: "KeycloakBackups");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ public class Program
|
|||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents()
|
.AddInteractiveServerComponents()
|
||||||
.AddInteractiveWebAssemblyComponents()
|
|
||||||
.AddAuthenticationStateSerialization();
|
.AddAuthenticationStateSerialization();
|
||||||
|
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
@@ -221,7 +220,6 @@ public class Program
|
|||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapRazorComponents<EntKube.Web.Components.App>()
|
app.MapRazorComponents<EntKube.Web.Components.App>()
|
||||||
.AddInteractiveServerRenderMode()
|
.AddInteractiveServerRenderMode()
|
||||||
.AddInteractiveWebAssemblyRenderMode()
|
|
||||||
.AddAdditionalAssemblies(typeof(Client._Imports).Assembly);
|
.AddAdditionalAssemblies(typeof(Client._Imports).Assembly);
|
||||||
|
|
||||||
// Add additional endpoints required by the Identity /Account Razor components.
|
// Add additional endpoints required by the Identity /Account Razor components.
|
||||||
@@ -241,6 +239,43 @@ public class Program
|
|||||||
return Results.File(data, "application/gzip", filename);
|
return Results.File(data, "application/gzip", filename);
|
||||||
}).RequireAuthorization();
|
}).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.
|
// Git webhook endpoint — receives push events from GitHub, Azure DevOps, etc.
|
||||||
// Route: POST /api/git/webhook/{tenantSlug}
|
// Route: POST /api/git/webhook/{tenantSlug}
|
||||||
// No authentication required (public endpoint); signature verification is
|
// No authentication required (public endpoint); signature verification is
|
||||||
@@ -263,30 +298,41 @@ public class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to apply pending EF Core migrations, retrying up to 5 times with
|
/// Applies all pending EF Core migrations, retrying with exponential backoff.
|
||||||
/// exponential backoff. This handles the common scenario in containerized
|
/// Throws on final failure so the container exits and the orchestrator restarts it
|
||||||
/// deployments where the database container isn't ready when the app starts.
|
/// rather than running with a broken schema.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void MigrateWithRetry(DbContext db, ILogger logger)
|
private static void MigrateWithRetry(DbContext db, ILogger logger)
|
||||||
{
|
{
|
||||||
int maxRetries = 5;
|
int maxRetries = 10;
|
||||||
int delayMs = 1000;
|
int delayMs = 1000;
|
||||||
|
|
||||||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||||
{
|
{
|
||||||
try
|
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();
|
db.Database.Migrate();
|
||||||
logger.LogInformation("Database migrations applied successfully.");
|
logger.LogInformation("Database migrations applied successfully.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (attempt < maxRetries)
|
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);
|
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.AppId, s.ComponentId, s.StorageLinkId, s.OpenStackConnectionId,
|
||||||
s.CnpgClusterId, s.CnpgDatabaseId, s.MongoDatabaseId, s.MongoClusterId,
|
s.CnpgClusterId, s.CnpgDatabaseId, s.MongoDatabaseId, s.MongoClusterId,
|
||||||
s.RegisteredPostgresDatabaseId, s.RabbitMQClusterId,
|
s.RegisteredPostgresDatabaseId, s.RabbitMQClusterId,
|
||||||
|
s.RedisClusterId, s.VpnRemoteEndpointId, s.GitRepositoryId, s.CustomerGitCredentialId,
|
||||||
s.SyncToKubernetes, s.KubernetesClusterId, s.KubernetesSecretName, s.KubernetesNamespace,
|
s.SyncToKubernetes, s.KubernetesClusterId, s.KubernetesSecretName, s.KubernetesNamespace,
|
||||||
s.CreatedAt, s.UpdatedAt));
|
s.CreatedAt, s.UpdatedAt));
|
||||||
}
|
}
|
||||||
@@ -127,6 +128,14 @@ public class BackupService(
|
|||||||
RegisteredPostgresDatabases = await db.RegisteredPostgresDatabases.AsNoTracking().ToListAsync(),
|
RegisteredPostgresDatabases = await db.RegisteredPostgresDatabases.AsNoTracking().ToListAsync(),
|
||||||
DatabaseBindings = await db.DatabaseBindings.AsNoTracking().ToListAsync(),
|
DatabaseBindings = await db.DatabaseBindings.AsNoTracking().ToListAsync(),
|
||||||
MessagingBindings = await db.MessagingBindings.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(),
|
KeycloakComponentConfigs = await db.KeycloakComponentConfigs.AsNoTracking().ToListAsync(),
|
||||||
KeycloakThemes = await db.KeycloakThemes.AsNoTracking().ToListAsync(),
|
KeycloakThemes = await db.KeycloakThemes.AsNoTracking().ToListAsync(),
|
||||||
KeycloakRealms = await db.KeycloakRealms.AsNoTracking().ToListAsync(),
|
KeycloakRealms = await db.KeycloakRealms.AsNoTracking().ToListAsync(),
|
||||||
@@ -161,14 +170,31 @@ public class BackupService(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task ImportAsync(Stream bundleStream, bool wipeExisting)
|
public async Task ImportAsync(Stream bundleStream, bool wipeExisting)
|
||||||
{
|
{
|
||||||
await using GZipStream gz = new(bundleStream, CompressionMode.Decompress);
|
// Buffer the stream so we can inspect the magic bytes without consuming them.
|
||||||
BackupBundle? bundle = await JsonSerializer.DeserializeAsync<BackupBundle>(gz, JsonOptions)
|
// (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.");
|
?? throw new InvalidDataException("Failed to deserialize backup bundle.");
|
||||||
|
|
||||||
if (bundle.Version != 1)
|
if (bundle.Version != 1)
|
||||||
throw new InvalidDataException($"Unsupported backup version: {bundle.Version}. Only version 1 is supported.");
|
throw new InvalidDataException($"Unsupported backup version: {bundle.Version}. Only version 1 is supported.");
|
||||||
|
|
||||||
await using ApplicationDbContext db = dbFactory.CreateDbContext();
|
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();
|
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -217,6 +243,11 @@ public class BackupService(
|
|||||||
await InsertEntities(db, db.Environments, bundle.Environments);
|
await InsertEntities(db, db.Environments, bundle.Environments);
|
||||||
await InsertEntities(db, db.Customers, bundle.Customers);
|
await InsertEntities(db, db.Customers, bundle.Customers);
|
||||||
await InsertEntities(db, db.CustomerAccesses, bundle.CustomerAccesses);
|
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.Apps, bundle.Apps);
|
||||||
await InsertEntities(db, db.AppEnvironments, bundle.AppEnvironments);
|
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.OpenStackConnections, bundle.OpenStackConnections);
|
||||||
await InsertEntities(db, db.KubernetesClusters, bundle.KubernetesClusters);
|
await InsertEntities(db, db.KubernetesClusters, bundle.KubernetesClusters);
|
||||||
await InsertEntities(db, db.ClusterComponents, bundle.ClusterComponents);
|
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.ExternalRoutes, bundle.ExternalRoutes);
|
||||||
await InsertEntities(db, db.StorageLinks, bundle.StorageLinks);
|
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).
|
// Deployments — apps and clusters must exist first (RESTRICT FKs).
|
||||||
await InsertEntities(db, db.AppDeployments, bundle.AppDeployments);
|
await InsertEntities(db, db.AppDeployments, bundle.AppDeployments);
|
||||||
await InsertEntities(db, db.DeploymentManifests, bundle.DeploymentManifests);
|
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.SlaTargets, bundle.SlaTargets);
|
||||||
await InsertEntities(db, db.MaintenanceWindows, bundle.MaintenanceWindows);
|
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 — 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.
|
// secrets with it. The new DEK is sealed with the new server's root key.
|
||||||
Dictionary<Guid, byte[]> newDekByVaultId = [];
|
Dictionary<Guid, byte[]> newDekByVaultId = [];
|
||||||
@@ -298,6 +372,10 @@ public class BackupService(
|
|||||||
CnpgDatabaseId = sr.CnpgDatabaseId, MongoDatabaseId = sr.MongoDatabaseId,
|
CnpgDatabaseId = sr.CnpgDatabaseId, MongoDatabaseId = sr.MongoDatabaseId,
|
||||||
MongoClusterId = sr.MongoClusterId, RegisteredPostgresDatabaseId = sr.RegisteredPostgresDatabaseId,
|
MongoClusterId = sr.MongoClusterId, RegisteredPostgresDatabaseId = sr.RegisteredPostgresDatabaseId,
|
||||||
RabbitMQClusterId = sr.RabbitMQClusterId,
|
RabbitMQClusterId = sr.RabbitMQClusterId,
|
||||||
|
RedisClusterId = sr.RedisClusterId,
|
||||||
|
VpnRemoteEndpointId = sr.VpnRemoteEndpointId,
|
||||||
|
GitRepositoryId = sr.GitRepositoryId,
|
||||||
|
CustomerGitCredentialId = sr.CustomerGitCredentialId,
|
||||||
SyncToKubernetes = sr.SyncToKubernetes, KubernetesClusterId = sr.KubernetesClusterId,
|
SyncToKubernetes = sr.SyncToKubernetes, KubernetesClusterId = sr.KubernetesClusterId,
|
||||||
KubernetesSecretName = sr.KubernetesSecretName, KubernetesNamespace = sr.KubernetesNamespace,
|
KubernetesSecretName = sr.KubernetesSecretName, KubernetesNamespace = sr.KubernetesNamespace,
|
||||||
CreatedAt = sr.CreatedAt, UpdatedAt = sr.UpdatedAt,
|
CreatedAt = sr.CreatedAt, UpdatedAt = sr.UpdatedAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user