diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5756664 --- /dev/null +++ b/.env.example @@ -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= +REGISTRY_PASSWORD= + +# --- 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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..ff6d0eb --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index 7910237..38888ca 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..5081b1a --- /dev/null +++ b/Caddyfile @@ -0,0 +1,7 @@ +{ + email {env.ACME_EMAIL} +} + +{env.DOMAIN} { + reverse_proxy entkube:8080 +} diff --git a/Dockerfile b/Dockerfile index 90029eb..d2dbdca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . diff --git a/README.md b/README.md index f802315..77079d8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,59 @@ EntKube/ └── EntKube.Client/ # WebAssembly client project ``` +## Docker + +### Build and push + +> **Apple Silicon (M-series) Mac:** servers are typically `linux/amd64`. Use `docker buildx` to cross-compile and push in one step — the `--push` flag is required because multi-platform images cannot be loaded into the local Docker daemon. + +```bash +# One-time setup +docker buildx create --use --name multiarch + +# Build for amd64 and push directly to the registry +docker buildx build --platform linux/amd64 \ + -t entit.azurecr.io/entkube:latest \ + --push . + +# Tag a versioned release +docker buildx build --platform linux/amd64 \ + -t entit.azurecr.io/entkube:latest \ + -t entit.azurecr.io/entkube:1.0.0 \ + --push . +``` + +Replace `entit.azurecr.io/entkube` with your actual registry and image path. + +### Run with Docker Compose + +Copy `.env.example` to `.env` and fill in the required values, then authenticate with the registry and start the stack: + +```bash +# Authenticate with the container registry (once per machine / token expiry) +docker login entit.azurecr.io -u $REGISTRY_USERNAME -p $REGISTRY_PASSWORD + +docker compose up -d +``` + +The app will be available at `http://localhost:8080`. + +> **Data safety** — `docker compose down` does **not** delete the database volume. +> Only `docker compose down -v` removes volumes. + +### Required configuration + +| Variable | Description | +|---|---| +| `DOMAIN` | Public domain pointing at the server (e.g. `entkube.example.com`). | +| `ACME_EMAIL` | Email for Let's Encrypt account and expiry notices. | +| `Vault__RootKey` | 32-byte base64 key for secret encryption. Generate with `openssl rand -base64 32`. | +| `POSTGRES_PASSWORD` | Password for the Postgres database (set in `.env`). | + +### TLS / HTTPS + +Caddy is the reverse proxy and handles TLS automatically. On first start it obtains a Let's Encrypt certificate for `DOMAIN` and renews it automatically before it expires. Ports **80** and **443** must be open and your DNS must point at the server before starting the stack. + ## License See [LICENSE](LICENSE). diff --git a/docker-compose.yml b/docker-compose.yml index efcf487..8bfc5c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/EntKube.Web.Client/EntKube.Web.Client.csproj b/src/EntKube.Web.Client/EntKube.Web.Client.csproj index 8de45fa..647b276 100644 --- a/src/EntKube.Web.Client/EntKube.Web.Client.csproj +++ b/src/EntKube.Web.Client/EntKube.Web.Client.csproj @@ -7,11 +7,14 @@ true Default true + + false - + diff --git a/src/EntKube.Web.Client/Pages/Auth.razor b/src/EntKube.Web.Client/Pages/Auth.razor index e243741..4c57234 100644 --- a/src/EntKube.Web.Client/Pages/Auth.razor +++ b/src/EntKube.Web.Client/Pages/Auth.razor @@ -3,7 +3,7 @@ @using Microsoft.AspNetCore.Authorization @attribute [Authorize] -@rendermode InteractiveAuto +@rendermode InteractiveServer Auth diff --git a/src/EntKube.Web.Client/Pages/Counter.razor b/src/EntKube.Web.Client/Pages/Counter.razor index 1c21c80..be08b44 100644 --- a/src/EntKube.Web.Client/Pages/Counter.razor +++ b/src/EntKube.Web.Client/Pages/Counter.razor @@ -1,5 +1,5 @@ @page "/counter" -@rendermode InteractiveAuto +@rendermode InteractiveServer Counter diff --git a/src/EntKube.Web.Client/Program.cs b/src/EntKube.Web.Client/Program.cs index f118014..400e57a 100644 --- a/src/EntKube.Web.Client/Program.cs +++ b/src/EntKube.Web.Client/Program.cs @@ -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(); diff --git a/src/EntKube.Web/Components/Pages/Admin/AdminBackup.razor b/src/EntKube.Web/Components/Pages/Admin/AdminBackup.razor index e9327c0..10f7aff 100644 --- a/src/EntKube.Web/Components/Pages/Admin/AdminBackup.razor +++ b/src/EntKube.Web/Components/Pages/Admin/AdminBackup.razor @@ -4,8 +4,7 @@ @rendermode InteractiveServer @attribute [Authorize(Roles = "Admin")] -@inject BackupService BackupService -@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Nav Backup & Restore @@ -41,44 +40,38 @@
Restore from Backup

- Upload a .json.gz backup file produced by this application. + Upload a .json.gz or .json backup file produced by this application. Secrets are re-encrypted with the current server's root key on restore.

-
- -
- -
- - -
- - @if (wipeExisting) - { -
- - All current data will be permanently deleted before the - backup is loaded. This cannot be undone. Only proceed on a fresh - installation or when you intend a full replacement. +
+
+
- } - + + +
@@ -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; } } diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260519191900_AddKeycloakComponentConfig.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260519191900_AddKeycloakComponentConfig.cs index 56cebf0..c97b018 100644 --- a/src/EntKube.Web/Data/Migrations/Postgres/20260519191900_AddKeycloakComponentConfig.cs +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260519191900_AddKeycloakComponentConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -11,34 +11,9 @@ namespace EntKube.Web.Data.Migrations.Postgres /// 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(type: "uuid", nullable: false), + AccountTheme = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + DisplayName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + KeycloakComponentConfigId = table.Column(type: "uuid", nullable: false), + LinkedAppId = table.Column(type: "uuid", nullable: true), + LoginTheme = table.Column(type: "text", nullable: true), + RealmName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + TenantId = table.Column(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); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropForeignKey( - name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~", - table: "KeycloakRealms"); - - migrationBuilder.DropTable( - name: "KeycloakComponentConfigs"); - - migrationBuilder.RenameColumn( - name: "KeycloakComponentConfigId", - table: "KeycloakRealms", - newName: "KeycloakConnectionId"); - - migrationBuilder.RenameIndex( - name: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName", - table: "KeycloakRealms", - newName: "IX_KeycloakRealms_KeycloakConnectionId_RealmName"); - - migrationBuilder.AddColumn( - name: "KeycloakConnectionId", - table: "VaultSecrets", - type: "uuid", - nullable: true); - - migrationBuilder.CreateTable( - name: "KeycloakConnections", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - AdminPasswordSecretId = table.Column(type: "uuid", nullable: true), - KubernetesClusterId = table.Column(type: "uuid", nullable: false), - TenantId = table.Column(type: "uuid", nullable: false), - AdminUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), - AdminUsername = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_KeycloakConnections", x => x.Id); - table.ForeignKey( - name: "FK_KeycloakConnections_KubernetesClusters_KubernetesClusterId", - column: x => x.KubernetesClusterId, - principalTable: "KubernetesClusters", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_KeycloakConnections_Tenants_TenantId", - column: x => x.TenantId, - principalTable: "Tenants", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_KeycloakConnections_VaultSecrets_AdminPasswordSecretId", - column: x => x.AdminPasswordSecretId, - principalTable: "VaultSecrets", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - }); - - migrationBuilder.CreateIndex( - name: "IX_VaultSecrets_KeycloakConnectionId", - table: "VaultSecrets", - column: "KeycloakConnectionId"); - - migrationBuilder.CreateIndex( - name: "IX_KeycloakConnections_AdminPasswordSecretId", - table: "KeycloakConnections", - column: "AdminPasswordSecretId"); - - migrationBuilder.CreateIndex( - name: "IX_KeycloakConnections_KubernetesClusterId", - table: "KeycloakConnections", - column: "KubernetesClusterId"); - - migrationBuilder.CreateIndex( - name: "IX_KeycloakConnections_TenantId", - table: "KeycloakConnections", - column: "TenantId"); - - migrationBuilder.AddForeignKey( - name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId", - table: "KeycloakRealms", - column: "KeycloakConnectionId", - principalTable: "KeycloakConnections", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - - migrationBuilder.AddForeignKey( - name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId", - table: "VaultSecrets", - column: "KeycloakConnectionId", - principalTable: "KeycloakConnections", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); + migrationBuilder.DropTable(name: "KeycloakRealms"); + migrationBuilder.DropTable(name: "KeycloakComponentConfigs"); } } } diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260525203011_KeycloakBackupStorageLinkNullable.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260525203011_KeycloakBackupStorageLinkNullable.cs index ed8bca7..8451f4c 100644 --- a/src/EntKube.Web/Data/Migrations/Postgres/20260525203011_KeycloakBackupStorageLinkNullable.cs +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260525203011_KeycloakBackupStorageLinkNullable.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -11,51 +11,56 @@ namespace EntKube.Web.Data.Migrations.Postgres /// 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(type: "uuid", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + KeycloakRealmId = table.Column(type: "uuid", nullable: false), + LastError = table.Column(type: "text", nullable: true), + ObjectKey = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + RealmName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + SizeBytes = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "integer", nullable: false), + StorageLinkId = table.Column(type: "uuid", nullable: true), + TenantId = table.Column(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( - 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"); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropForeignKey( - name: "FK_KeycloakBackups_StorageLinks_StorageLinkId", - table: "KeycloakBackups"); - - migrationBuilder.AlterColumn( - 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"); } } } diff --git a/src/EntKube.Web/Program.cs b/src/EntKube.Web/Program.cs index b29b24d..76d0544 100644 --- a/src/EntKube.Web/Program.cs +++ b/src/EntKube.Web/Program.cs @@ -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() .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 } /// - /// 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. /// 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 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(); } } diff --git a/src/EntKube.Web/Services/BackupService.cs b/src/EntKube.Web/Services/BackupService.cs index 5493d9f..961dc34 100644 --- a/src/EntKube.Web/Services/BackupService.cs +++ b/src/EntKube.Web/Services/BackupService.cs @@ -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( /// public async Task ImportAsync(Stream bundleStream, bool wipeExisting) { - await using GZipStream gz = new(bundleStream, CompressionMode.Decompress); - BackupBundle? bundle = await JsonSerializer.DeserializeAsync(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(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 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,