blazor.web.js issues
Some checks failed
Build and Deploy / Deploy to server (push) Has been skipped
Build and Deploy / Build and push image (push) Failing after 6m0s

This commit is contained in:
Nils Blomgren
2026-06-08 16:48:36 +02:00
parent ffe41b4413
commit 343de77810
16 changed files with 459 additions and 266 deletions

26
.env.example Normal file
View 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
View 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
View File

@@ -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
View File

@@ -0,0 +1,7 @@
{
email {env.ACME_EMAIL}
}
{env.DOMAIN} {
reverse_proxy entkube:8080
}

View File

@@ -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 .

View File

@@ -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).

View File

@@ -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:

View File

@@ -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>

View File

@@ -3,7 +3,7 @@
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@rendermode InteractiveAuto
@rendermode InteractiveServer
<PageTitle>Auth</PageTitle>

View File

@@ -1,5 +1,5 @@
@page "/counter"
@rendermode InteractiveAuto
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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();
}
}

View File

@@ -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,