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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,8 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
namespace EntKube.Web.Client;
class Program
{
static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddAuthorizationCore(); builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthenticationStateDeserialization();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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