diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aedbea1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# .NET build artifacts +**/bin/ +**/obj/ + +# Node / frontend +**/node_modules/ +OTSSignsOrchestrator/ClientApp/dist/ + +# Built frontend (generated by Vite into wwwroot — included via Docker COPY from frontend-build stage) +OTSSignsOrchestrator/wwwroot/ + +# Test project +OTSSignsOrchestrator.Tests/ + +# Logs +logs/ +*.log + +# User-specific files +.vs/ +.vscode/ +*.user +*.suo + +# Secrets / local config overrides (never bake these into the image) +appsettings.Development.json +**/secrets.json + +# Git +.git/ +.gitignore + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml + +# Misc +README.md +CLAUDE.md +.github/ +templates/ diff --git a/.env.example b/.env.example index 8cfe70c..af8b66b 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,47 @@ -# OTSSignsOrchestrator.Server — required environment variables -# Copy to .env and fill in real values. +# OTSSignsOrchestrator — environment variables +# Copy to .env and fill in real values before running. -ConnectionStrings__OrchestratorDb=Host=localhost;Port=5432;Database=orchestrator_dev;Username=ots;Password=devpassword -Stripe__WebhookSecret=whsec_... -Stripe__SecretKey=sk_test_... +# ── PostgreSQL ─────────────────────────────────────────────────────────────── +# Used directly by the app. When running via docker-compose, POSTGRES_PASSWORD +# is also required so the postgres service can initialise the database. +ConnectionStrings__OrchestratorDb=Host=postgres;Port=5432;Database=orchestrator;Username=ots;Password=changeme +POSTGRES_PASSWORD=changeme + +# ── JWT ────────────────────────────────────────────────────────────────────── +# Key must be at least 32 characters (256-bit). Generate with: +# openssl rand -base64 32 Jwt__Key=change-me-to-a-random-256-bit-key +# Jwt__Issuer=OTSSignsOrchestrator # optional — has a default +# Jwt__Audience=OTSSignsOrchestrator # optional — has a default + +# ── Bitwarden Secrets Manager ──────────────────────────────────────────────── +# Machine account access token from https://vault.bitwarden.com +Bitwarden__AccessToken= +Bitwarden__OrganizationId= +# ProjectId is the default project for orchestrator config secrets +Bitwarden__ProjectId= +# InstanceProjectId is optional; instance-level secrets go here when set +# Bitwarden__InstanceProjectId= +# Bitwarden__IdentityUrl=https://identity.bitwarden.com # optional +# Bitwarden__ApiUrl=https://api.bitwarden.com # optional + +# ── Stripe ─────────────────────────────────────────────────────────────────── +Stripe__SecretKey=sk_test_... +Stripe__WebhookSecret=whsec_... + +# ── Authentik (SAML IdP) ───────────────────────────────────────────────────── Authentik__BaseUrl=https://auth.example.com Authentik__ApiToken= -SendGrid__ApiKey=SG.... -OTS_SIGNS_SERVER_URL=http://localhost:5000 +# UUID of the OTS signing certificate-key pair in Authentik +Authentik__OtsSigningKpId= +# Authentik__SourcePreAuthFlowSlug=default-source-pre-authentication # optional +# Authentik__SourceAuthFlowSlug=default-source-authentication # optional + +# ── Email (SendGrid) ───────────────────────────────────────────────────────── +Email__SendGridApiKey=SG.... +# Email__SenderEmail=noreply@otssigns.com # optional +# Email__SenderName=OTS Signs # optional + +# ── Git template repository ─────────────────────────────────────────────────── +# These are stored in Bitwarden at runtime; set here only for local dev without BW. +# Git__CacheDir=.template-cache # optional diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6a957f1..1a1e189 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,27 +2,21 @@ ## Architecture -Layered MVVM desktop app for provisioning and managing Xibo CMS instances on Docker Swarm. +Web-based system for provisioning and managing Xibo CMS instances on Docker Swarm. -- **OTSSignsOrchestrator.Core** — class library: services, EF Core data access, models, configuration. Reusable across UIs. -- **OTSSignsOrchestrator.Desktop** — Avalonia 11 UI: views, view models, DI setup. References Core. -- **templates/** — Docker Compose + PHP templates with `{{PLACEHOLDER}}` substitution. +- **OTSSignsOrchestrator** — ASP.NET Core API + React web UI (Vite + TypeScript + Tailwind CSS) + SignalR + Quartz scheduler. PostgreSQL 16. Contains all services, models, configuration, and business logic. +- **OTSSignsOrchestrator.Tests** — xUnit test project. ### Key patterns -- Services injected via `IServiceProvider` (registered in `App.axaml.cs` → `ConfigureServices()`) -- Singletons: stateful services (SSH connections, Docker CLI). Transient: stateless logic. +- Services injected via DI (registered in `Program.cs`) +- Singletons: stateful services (SSH connection factory). Scoped: per-request services (Docker CLI, secrets). Transient: stateless logic. - Configuration via `IOptions` bound from `appsettings.json` (see `AppOptions.cs` for all sections). - Bitwarden Secrets Manager is the source of truth for all sensitive config. `SettingsService` caches in-memory. -- Local SQLite DB (`otssigns-desktop.db`) stores SSH hosts + operation logs. Credentials encrypted via Data Protection API. - -### Scope & file discipline -**The Server project is net-new — keep concerns separated.** -- Never modify `OTSSignsOrchestrator.Core` or `OTSSignsOrchestrator.Desktop` unless the prompt explicitly says to. -- When in doubt, add new code to `OTSSignsOrchestrator.Server`. -- Never modify `XiboContext.cs` without explicit instruction. +- PostgreSQL database via `OrchestratorDbContext`. Credentials encrypted via Data Protection API. +- React frontend in `ClientApp/`, built to `wwwroot/` via Vite. Cookie-based JWT auth. ### External integrations -Xibo CMS REST API (OAuth2), Authentik SAML IdP, Bitwarden Secrets SDK, Docker Swarm (via SSH), Git (LibGit2Sharp), MySQL 8.4, NFS volumes, Pangolin/Newt VPN. +Xibo CMS REST API (OAuth2), Authentik SAML IdP, Bitwarden Secrets SDK, Docker Swarm (via SSH), Git (LibGit2Sharp), MySQL 8.4, NFS volumes, Pangolin/Newt VPN, Stripe, SendGrid. #### Xibo API rules — non-negotiable - `GET /api/application` is **BLOCKED**. Only POST and DELETE exist. @@ -43,17 +37,20 @@ Xibo CMS REST API (OAuth2), Authentik SAML IdP, Bitwarden Secrets SDK, Docker Sw ```bash # Build -dotnet build OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj +dotnet build -# Run -dotnet run --project OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj +# Run Server +dotnet run --project OTSSignsOrchestrator/OTSSignsOrchestrator.csproj -# No test suite currently — no xUnit/NUnit projects +# Run tests +dotnet test OTSSignsOrchestrator.Tests/OTSSignsOrchestrator.Tests.csproj + +# Frontend dev +cd OTSSignsOrchestrator/ClientApp && npm run dev ``` -- .NET 9.0, Avalonia 11.2.3, CommunityToolkit.Mvvm 8.4 -- Runtime identifiers: `linux-x64`, `win-x64`, `osx-x64`, `osx-arm64` -- EF Core migrations in `OTSSignsOrchestrator.Core/Migrations/` +- .NET 9.0, React 18, Vite, TypeScript, Tailwind CSS, shadcn/ui +- EF Core migrations in `OTSSignsOrchestrator/Migrations/` ### Test coverage non-negotiables Unit tests are **required** for: @@ -67,28 +64,10 @@ Integration tests **require** Testcontainers with a real PostgreSQL 16 instance ## Conventions -### ViewModels -- Inherit `ObservableObject` (CommunityToolkit.Mvvm). Use `[ObservableProperty]` for bindable fields and `[RelayCommand]` for commands. -- React to changes via `partial void OnXxxChanged(T value)` methods generated by the toolkit. -- Resolve services from `IServiceProvider` in constructors. Navigation via `MainWindowViewModel.CurrentView`. -- Confirmation dialogs use `Func> ConfirmAsync` property — wired by the View. - -### Avalonia threading — critical for stability -All SignalR message handlers and background thread continuations that touch `ObservableProperty` or `ObservableCollection` **MUST** be wrapped in: -```csharp -Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { ... }); -``` -**Failure to do this causes silent cross-thread exceptions in Avalonia.** Never suggest direct property assignment from a non-UI thread. - -### Views (Avalonia XAML) -- Compiled bindings enabled (`x:CompileBindings="True"`). DataTemplates in `MainWindow.axaml` map ViewModel types to View UserControls. -- Layout: DockPanel with status bar (bottom), sidebar nav (left), dynamic ContentControl (center). -- Style: Fluent theme, dark palette (`#0C0C14` accents). - ### Services - Interface + implementation pattern for testable services (`IXiboApiService`, `IDockerCliService`, etc.). -- `SshDockerCliService` is a singleton — **must call `SetHost(host)` before each operation** in loops. -- All long operations are `async Task`. Use `IsBusy` + `StatusMessage` properties for UI feedback. +- `SshDockerCliService` is scoped — **must call `SetHost(host)` before each operation** in loops. +- All long operations are `async Task`. ### Naming - Customer abbreviation: exactly 3 lowercase letters (`^[a-z]{3}$`). @@ -97,27 +76,23 @@ Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { ... }); - `AppConstants.SanitizeName()` filters to `[a-z0-9_-]`. ### Credential handling -Never store OAuth2 client secrets, Stripe keys, or SSH passwords in the database. Secrets go to the Bitwarden CLI wrapper only. `OauthAppRegistry` stores `clientId` only — never the secret. Log credentials to `JobStep` output **ONLY** as a last-resort break-glass fallback, and mark it explicitly as emergency recovery data in the log. +Never store OAuth2 client secrets, Stripe keys, or SSH passwords in the database. Secrets go to the Bitwarden CLI wrapper only. `OauthAppRegistry` stores `clientId` only — never the secret. ### Code generation verification -After generating any class that implements an interface, **verify all interface members are implemented.** After generating any pipeline, **verify all steps are implemented as `JobStep` entities with progress broadcast via `IHubContext`.** Do not stub steps as TODO — implement them fully or flag explicitly that the step requires external infrastructure access that cannot be completed in this context. +After generating any class that implements an interface, **verify all interface members are implemented.** After generating any pipeline, **verify all steps are implemented as `JobStep` entities with progress broadcast via `IHubContext`.** Do not stub steps as TODO — implement them fully or flag explicitly. ### Data layer -- Entities in `Core/Models/Entities/`, DTOs in `Core/Models/DTOs/`. -- `XiboContext` applies unique index on `SshHost.Label` and encrypts credential fields. -- Add new migrations via: `dotnet ef migrations add --project OTSSignsOrchestrator.Core --startup-project OTSSignsOrchestrator.Desktop` +- Entities in `Core/Models/Entities/` and `Server/Data/Entities/`. +- DTOs in `Core/Models/DTOs/`. +- `OrchestratorDbContext` is the primary database context (PostgreSQL). ### Immutability enforcement -**AuditLog, Message, and Evidence are append-only by design.** Never generate `Update()` or `Delete()` methods on these repositories. Add an explicit comment on each repository class: -```csharp -// IMMUTABLE — no update or delete operations permitted. -``` +**AuditLog, Message, and Evidence are append-only by design.** Never generate `Update()` or `Delete()` methods on these repositories. ## Pitfalls -- **SSH singleton state**: `SshDockerCliService.SetHost()` must be called before each host operation — stale host causes silent failures. +- **SSH host state**: `SshDockerCliService.SetHost()` must be called before each host operation — stale host causes silent failures. - **Bitwarden cache**: After creating secrets during deployment, call `FlushCacheAsync()` before reading them back. -- **Data Protection keys**: Stored in `%APPDATA%/OTSSignsOrchestrator/keys`. If lost, encrypted SSH passwords are unrecoverable. - **Docker volumes are sticky**: Failed deploys leave volumes with old NFS driver options. Use `PurgeStaleVolumes: true` to force fresh volumes (causes data loss). - **No saga/rollback**: Instance creation spans Git → MySQL → Docker → Xibo. Partial failures leave orphaned resources; cleanup is manual via `OperationLog`. - **Template CIFS→NFS compat**: Old `{{CIFS_*}}` tokens still render correctly as NFS equivalents. diff --git a/.gitignore b/.gitignore index 8865743..0819793 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,14 @@ -# .gitignore for C#/.NET projects on macOS +# .gitignore for ASP.NET Core + React (Vite) project on macOS # Generated for Visual Studio, Rider, and dotnet CLI workflows -# Visual Studio +# Visual Studio & Rider .vs/ +.idea/ *.suo *.user *.userosscache *.sln.docstates +*.DotSettings.user # Build results [Bb]in/ @@ -15,19 +17,15 @@ build/ publish/ artifacts/ -# Rider -.idea/ - # Resharper _ReSharper*/ -*.DotSettings.user # NuGet *.nupkg packages/ project.lock.json -# Dotnet +# Dotnet & EF Core *.db *.db-journal secrets.json @@ -36,6 +34,8 @@ dotnet_user_secrets # Logs *.log TestResults/ +coverage/ +*.trx # OS generated files .DS_Store @@ -51,9 +51,23 @@ Icon *~ *.tmp +# Node/React Frontend (Vite) +OTSSignsOrchestrator/ClientApp/node_modules/ +OTSSignsOrchestrator/ClientApp/dist/ +OTSSignsOrchestrator/ClientApp/.env.local +OTSSignsOrchestrator/ClientApp/.env.*.local +OTSSignsOrchestrator/ClientApp/npm-debug.log* +OTSSignsOrchestrator/ClientApp/yarn-debug.log* +OTSSignsOrchestrator/ClientApp/yarn-error.log* + # Docker docker-compose.override.yml -# Ignore appsettings development files (if you keep secrets locally) +# Configuration & Secrets appsettings.Development.json -.template-cache/ \ No newline at end of file +.template-cache/ +.env +*.env + +# Application-specific +logs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4ba37dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OTS Signs Orchestrator — a .NET 9.0 system for provisioning and managing Xibo CMS instances on Docker Swarm. Two projects in one solution: + +- **OTSSignsOrchestrator** — ASP.NET Core API + React web UI + SignalR + Quartz scheduler. PostgreSQL 16. Contains all services, models, configuration, and business logic. +- **OTSSignsOrchestrator.Tests** — xUnit test project. + +External integrations: Xibo CMS API (OAuth2), Authentik (SAML IdP), Bitwarden Secrets, Docker Swarm (SSH), Git (LibGit2Sharp), MySQL 8.4, Stripe, SendGrid, NFS volumes. + +## Build & Run Commands + +```bash +# Build entire solution +dotnet build + +# Run application +dotnet run --project OTSSignsOrchestrator/OTSSignsOrchestrator.csproj + +# Run tests +dotnet test OTSSignsOrchestrator.Tests/OTSSignsOrchestrator.Tests.csproj + +# Run a single test +dotnet test OTSSignsOrchestrator.Tests --filter "FullyQualifiedName~TestClassName.TestMethodName" + +# Frontend dev server (from ClientApp/) +cd OTSSignsOrchestrator/ClientApp && npm run dev + +# Build frontend for production (outputs to wwwroot/) +cd OTSSignsOrchestrator/ClientApp && npm run build + +# EF Core migrations +dotnet ef migrations add --project OTSSignsOrchestrator --startup-project OTSSignsOrchestrator + +# Local dev PostgreSQL +docker compose -f docker-compose.dev.yml up -d +``` + +## Architecture + +The application uses a job-queue architecture with a React web UI: + +1. **React Web UI** (`ClientApp/`) — Vite + React + TypeScript + Tailwind CSS, served from `wwwroot/`. Cookie-based JWT auth (httpOnly, Secure, SameSite=Strict). +2. **REST API** (`Api/`) — Minimal API endpoint groups via `.MapGroup()` with JWT auth +3. **SignalR Hub** (`Hubs/FleetHub.cs`) — Real-time updates to web UI clients +4. **ProvisioningWorker** (`Workers/`) — Background service that polls `Jobs` table, claims jobs, resolves the correct `IProvisioningPipeline`, and executes steps +5. **Pipelines** — Each job type has a pipeline (Phase1, Phase2, BYOI SAML, Suspend, Reactivate, Decommission, etc.). Steps emit `JobStep` records broadcast via SignalR +6. **HealthCheckEngine** (`Health/`) — Background service running 16 health check types +7. **Quartz Jobs** (`Jobs/`) — Scheduled tasks (cert expiry, daily snapshots, reports) +8. **Stripe Webhooks** (`Webhooks/`) — Idempotent webhook processing + +**Data flow:** Web UI creates `Job` → `ProvisioningWorker` claims it → pipeline runs steps → `JobStep` records broadcast via SignalR → Web UI updates in real-time. + +## Project Structure + +``` +OTSSignsOrchestrator/ +├── Api/ # Minimal API endpoint groups +├── Auth/ # JWT/auth services +├── ClientApp/ # React + Vite frontend +├── Clients/ # External API clients (Xibo, Authentik) +├── Configuration/ # AppConstants, AppOptions +├── Data/ # OrchestratorDbContext +│ └── Entities/ # EF Core entity models +├── Health/ # Health check engine + checks +├── Hubs/ # SignalR hubs +├── Jobs/ # Quartz scheduled jobs +├── Models/DTOs/ # Data transfer objects +├── Reports/ # PDF report generation +├── Services/ # Business logic + integrations +├── Webhooks/ # Stripe webhook handler +├── Workers/ # Provisioning pipelines + worker +└── wwwroot/ # Built frontend assets +``` + +## Critical Rules + +### Xibo API — non-negotiable +- `GET /api/application` is **BLOCKED** — only POST and DELETE exist +- Group endpoints are `/api/group`, never `/api/usergroup` +- Feature assignment is `POST /api/group/{id}/acl`, NOT `/features` +- **Always pass `length=200`** and use `GetAllPagesAsync()` — default pagination is 10 items, causing silent data truncation +- OAuth2 client secret returned **ONCE** on creation — capture immediately + +### Stripe webhooks — idempotency mandatory +- Check `StripeEvents` table for `stripe_event_id` before processing +- Insert the `StripeEvent` row first, then process + +### No AI autonomy in infrastructure actions +- All infrastructure actions must flow through the `ProvisioningWorker` job queue via an operator-initiated `Job` record + +### Immutability +`AuditLog`, `Message`, and `Evidence` are append-only. Never generate Update/Delete methods on their repositories. + +### Credential handling +Never store secrets in the database. Secrets go to Bitwarden only. `OauthAppRegistry` stores `clientId` only. + +## Naming Conventions + +- Customer abbreviation: exactly 3 lowercase letters (`^[a-z]{3}$`) +- Stack name: `{abbrev}-cms-stack`, Service: `{abbrev}-web`, DB: `{abbrev}_cms_db` +- Secret names via `AppConstants` helpers +- `AppConstants.SanitizeName()` filters to `[a-z0-9_-]` + +## Testing Requirements + +- Integration tests **require** Testcontainers with real PostgreSQL 16 — no SQLite substitutions +- Unit tests required for: evidence hashing, AI context assembly, pattern detection, abbreviation uniqueness, Stripe idempotency + +## Pitfalls + +- **SSH host state**: `SshDockerCliService.SetHost()` must be called before each host operation in loops +- **Bitwarden cache**: Call `FlushCacheAsync()` after creating secrets before reading them back +- **No saga/rollback**: Partial failures across Git → MySQL → Docker → Xibo leave orphaned resources; cleanup is manual +- **Docker volumes are sticky**: Failed deploys leave volumes with old NFS driver options +- **Template CIFS→NFS compat**: Old `{{CIFS_*}}` tokens still render correctly as NFS equivalents + +## Code Generation Checklist + +- After generating a class implementing an interface, verify all members are implemented +- After generating a pipeline, verify all steps produce `JobStep` entities with progress broadcast via `IHubContext` +- Do not stub steps as TODO — implement fully or flag explicitly diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db3a7b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# ── Stage 1: Build React frontend ──────────────────────────────────────────── +FROM node:20-alpine AS frontend-build +WORKDIR /app/ClientApp +COPY OTSSignsOrchestrator/ClientApp/package.json OTSSignsOrchestrator/ClientApp/package-lock.json* ./ +RUN npm ci +COPY OTSSignsOrchestrator/ClientApp/ ./ +# Build outputs to ../wwwroot (relative to ClientApp) +RUN npm run build + +# ── Stage 2: Publish .NET app ───────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS dotnet-build +WORKDIR /src + +# Restore dependencies (layer-cached separately for fast rebuilds) +COPY OTSSignsOrchestrator/OTSSignsOrchestrator.csproj OTSSignsOrchestrator/ +RUN dotnet restore OTSSignsOrchestrator/OTSSignsOrchestrator.csproj + +# Copy source (excluding ClientApp — frontend handled in stage 1) +COPY OTSSignsOrchestrator/ OTSSignsOrchestrator/ + +# Copy built frontend assets from stage 1 +COPY --from=frontend-build /app/wwwroot OTSSignsOrchestrator/wwwroot/ + +RUN dotnet publish OTSSignsOrchestrator/OTSSignsOrchestrator.csproj \ + -c Release \ + -o /app/publish \ + --no-restore + +# ── Stage 3: Runtime image ──────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime +WORKDIR /app + +# LibGit2Sharp requires git native libraries (libgit2 is bundled in the NuGet package, +# but git2-ssh requires libssh2 on Linux) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libssl3 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=dotnet-build /app/publish . + +# Data Protection keys must survive restarts — mount a volume here +VOLUME ["/app/dataprotection-keys"] + +# Expose HTTP only — use a reverse proxy (nginx/Caddy/Traefik) for TLS termination +EXPOSE 8080 + +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +ENTRYPOINT ["dotnet", "OTSSignsOrchestrator.dll"] diff --git a/OTSSignsOrchestrator.Core/Data/DesignTimeDbContextFactory.cs b/OTSSignsOrchestrator.Core/Data/DesignTimeDbContextFactory.cs deleted file mode 100644 index df0722f..0000000 --- a/OTSSignsOrchestrator.Core/Data/DesignTimeDbContextFactory.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.AspNetCore.DataProtection; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; -using Microsoft.Extensions.DependencyInjection; - -namespace OTSSignsOrchestrator.Core.Data; - -/// -/// Design-time factory for EF Core migrations tooling. -/// -public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory -{ - public XiboContext CreateDbContext(string[] args) - { - var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder.UseSqlite("Data Source=design-time.db"); - - // Set up a temporary DataProtection provider for design-time use - var services = new ServiceCollection(); - services.AddDataProtection() - .SetApplicationName("OTSSignsOrchestrator"); - var sp = services.BuildServiceProvider(); - var dpProvider = sp.GetRequiredService(); - - return new XiboContext(optionsBuilder.Options, dpProvider); - } -} diff --git a/OTSSignsOrchestrator.Core/Data/XiboContext.cs b/OTSSignsOrchestrator.Core/Data/XiboContext.cs deleted file mode 100644 index 15ea03d..0000000 --- a/OTSSignsOrchestrator.Core/Data/XiboContext.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.AspNetCore.DataProtection; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Models.Entities; - -namespace OTSSignsOrchestrator.Core.Data; - -public class XiboContext : DbContext -{ - private readonly IDataProtectionProvider? _dataProtection; - - public XiboContext(DbContextOptions options, IDataProtectionProvider? dataProtection = null) - : base(options) - { - _dataProtection = dataProtection; - } - - public DbSet SshHosts => Set(); - public DbSet OperationLogs => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - // --- SshHost --- - modelBuilder.Entity(entity => - { - entity.HasIndex(e => e.Label).IsUnique(); - - if (_dataProtection != null) - { - var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.SshHost"); - var passphraseConverter = new ValueConverter( - v => v != null ? protector.Protect(v) : null, - v => v != null ? protector.Unprotect(v) : null); - var passwordConverter = new ValueConverter( - v => v != null ? protector.Protect(v) : null, - v => v != null ? protector.Unprotect(v) : null); - - entity.Property(e => e.KeyPassphrase).HasConversion(passphraseConverter); - entity.Property(e => e.Password).HasConversion(passwordConverter); - } - }); - - // --- OperationLog --- - modelBuilder.Entity(entity => - { - entity.HasIndex(e => e.Timestamp); - entity.HasIndex(e => e.StackName); - entity.HasIndex(e => e.Operation); - }); - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260217004115_DesktopInitial.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260217004115_DesktopInitial.Designer.cs deleted file mode 100644 index ee2da66..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260217004115_DesktopInitial.Designer.cs +++ /dev/null @@ -1,290 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - [Migration("20260217004115_DesktopInitial")] - partial class DesktopInitial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CmsServerName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Constraints") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("HostHttpPort") - .HasColumnType("INTEGER"); - - b.Property("LibraryHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("SmtpServer") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SmtpUsername") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SshHostId") - .HasColumnType("TEXT"); - - b.Property("StackName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("TemplateCacheKey") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("TemplateLastFetch") - .HasColumnType("TEXT"); - - b.Property("TemplateRepoPat") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("TemplateRepoUrl") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("ThemeHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("XiboApiTestStatus") - .HasColumnType("INTEGER"); - - b.Property("XiboApiTestedAt") - .HasColumnType("TEXT"); - - b.Property("XiboPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("XiboUsername") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CustomerName"); - - b.HasIndex("SshHostId"); - - b.HasIndex("StackName") - .IsUnique(); - - b.ToTable("CmsInstances"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("InstanceId") - .HasColumnType("TEXT"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("InstanceId"); - - b.HasIndex("Operation"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("IsGlobal") - .HasColumnType("INTEGER"); - - b.Property("LastRotatedAt") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("SecretMetadata"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost") - .WithMany("Instances") - .HasForeignKey("SshHostId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("SshHost"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance") - .WithMany("OperationLogs") - .HasForeignKey("InstanceId"); - - b.Navigation("Instance"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Navigation("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Navigation("Instances"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260217004115_DesktopInitial.cs b/OTSSignsOrchestrator.Core/Migrations/20260217004115_DesktopInitial.cs deleted file mode 100644 index c112a5f..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260217004115_DesktopInitial.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - /// - public partial class DesktopInitial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "SecretMetadata", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), - IsGlobal = table.Column(type: "INTEGER", nullable: false), - CustomerName = table.Column(type: "TEXT", maxLength: 100, nullable: true), - CreatedAt = table.Column(type: "TEXT", nullable: false), - LastRotatedAt = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_SecretMetadata", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "SshHosts", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - Label = table.Column(type: "TEXT", maxLength: 200, nullable: false), - Host = table.Column(type: "TEXT", maxLength: 500, nullable: false), - Port = table.Column(type: "INTEGER", nullable: false), - Username = table.Column(type: "TEXT", maxLength: 100, nullable: false), - PrivateKeyPath = table.Column(type: "TEXT", maxLength: 1000, nullable: true), - KeyPassphrase = table.Column(type: "TEXT", maxLength: 2000, nullable: true), - Password = table.Column(type: "TEXT", maxLength: 2000, nullable: true), - UseKeyAuth = table.Column(type: "INTEGER", nullable: false), - CreatedAt = table.Column(type: "TEXT", nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: false), - LastTestedAt = table.Column(type: "TEXT", nullable: true), - LastTestSuccess = table.Column(type: "INTEGER", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_SshHosts", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "CmsInstances", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - CustomerName = table.Column(type: "TEXT", maxLength: 100, nullable: false), - StackName = table.Column(type: "TEXT", maxLength: 100, nullable: false), - CmsServerName = table.Column(type: "TEXT", maxLength: 200, nullable: false), - HostHttpPort = table.Column(type: "INTEGER", nullable: false), - ThemeHostPath = table.Column(type: "TEXT", maxLength: 500, nullable: false), - LibraryHostPath = table.Column(type: "TEXT", maxLength: 500, nullable: false), - SmtpServer = table.Column(type: "TEXT", maxLength: 200, nullable: false), - SmtpUsername = table.Column(type: "TEXT", maxLength: 200, nullable: false), - Constraints = table.Column(type: "TEXT", maxLength: 2000, nullable: true), - TemplateRepoUrl = table.Column(type: "TEXT", maxLength: 500, nullable: false), - TemplateRepoPat = table.Column(type: "TEXT", maxLength: 500, nullable: true), - TemplateLastFetch = table.Column(type: "TEXT", nullable: true), - TemplateCacheKey = table.Column(type: "TEXT", maxLength: 100, nullable: true), - Status = table.Column(type: "INTEGER", nullable: false), - XiboUsername = table.Column(type: "TEXT", maxLength: 500, nullable: true), - XiboPassword = table.Column(type: "TEXT", maxLength: 1000, nullable: true), - XiboApiTestStatus = table.Column(type: "INTEGER", nullable: false), - XiboApiTestedAt = table.Column(type: "TEXT", nullable: true), - CreatedAt = table.Column(type: "TEXT", nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: false), - DeletedAt = table.Column(type: "TEXT", nullable: true), - SshHostId = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_CmsInstances", x => x.Id); - table.ForeignKey( - name: "FK_CmsInstances_SshHosts_SshHostId", - column: x => x.SshHostId, - principalTable: "SshHosts", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - }); - - migrationBuilder.CreateTable( - name: "OperationLogs", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - Operation = table.Column(type: "INTEGER", nullable: false), - InstanceId = table.Column(type: "TEXT", nullable: true), - UserId = table.Column(type: "TEXT", maxLength: 200, nullable: true), - Status = table.Column(type: "INTEGER", nullable: false), - Message = table.Column(type: "TEXT", maxLength: 2000, nullable: true), - DurationMs = table.Column(type: "INTEGER", nullable: true), - Timestamp = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_OperationLogs", x => x.Id); - table.ForeignKey( - name: "FK_OperationLogs_CmsInstances_InstanceId", - column: x => x.InstanceId, - principalTable: "CmsInstances", - principalColumn: "Id"); - }); - - migrationBuilder.CreateIndex( - name: "IX_CmsInstances_CustomerName", - table: "CmsInstances", - column: "CustomerName"); - - migrationBuilder.CreateIndex( - name: "IX_CmsInstances_SshHostId", - table: "CmsInstances", - column: "SshHostId"); - - migrationBuilder.CreateIndex( - name: "IX_CmsInstances_StackName", - table: "CmsInstances", - column: "StackName", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_OperationLogs_InstanceId", - table: "OperationLogs", - column: "InstanceId"); - - migrationBuilder.CreateIndex( - name: "IX_OperationLogs_Operation", - table: "OperationLogs", - column: "Operation"); - - migrationBuilder.CreateIndex( - name: "IX_OperationLogs_Timestamp", - table: "OperationLogs", - column: "Timestamp"); - - migrationBuilder.CreateIndex( - name: "IX_SecretMetadata_Name", - table: "SecretMetadata", - column: "Name", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_SshHosts_Label", - table: "SshHosts", - column: "Label", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "OperationLogs"); - - migrationBuilder.DropTable( - name: "SecretMetadata"); - - migrationBuilder.DropTable( - name: "CmsInstances"); - - migrationBuilder.DropTable( - name: "SshHosts"); - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218140239_AddCustomerAbbrev.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260218140239_AddCustomerAbbrev.Designer.cs deleted file mode 100644 index abd7494..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260218140239_AddCustomerAbbrev.Designer.cs +++ /dev/null @@ -1,295 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - [Migration("20260218140239_AddCustomerAbbrev")] - partial class AddCustomerAbbrev - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CmsServerName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Constraints") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerAbbrev") - .IsRequired() - .HasMaxLength(3) - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("HostHttpPort") - .HasColumnType("INTEGER"); - - b.Property("LibraryHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("SmtpServer") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SmtpUsername") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SshHostId") - .HasColumnType("TEXT"); - - b.Property("StackName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("TemplateCacheKey") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("TemplateLastFetch") - .HasColumnType("TEXT"); - - b.Property("TemplateRepoPat") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("TemplateRepoUrl") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("ThemeHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("XiboApiTestStatus") - .HasColumnType("INTEGER"); - - b.Property("XiboApiTestedAt") - .HasColumnType("TEXT"); - - b.Property("XiboPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("XiboUsername") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CustomerName"); - - b.HasIndex("SshHostId"); - - b.HasIndex("StackName") - .IsUnique(); - - b.ToTable("CmsInstances"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("InstanceId") - .HasColumnType("TEXT"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("InstanceId"); - - b.HasIndex("Operation"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("IsGlobal") - .HasColumnType("INTEGER"); - - b.Property("LastRotatedAt") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("SecretMetadata"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost") - .WithMany("Instances") - .HasForeignKey("SshHostId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("SshHost"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance") - .WithMany("OperationLogs") - .HasForeignKey("InstanceId"); - - b.Navigation("Instance"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Navigation("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Navigation("Instances"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218140239_AddCustomerAbbrev.cs b/OTSSignsOrchestrator.Core/Migrations/20260218140239_AddCustomerAbbrev.cs deleted file mode 100644 index 43b24b8..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260218140239_AddCustomerAbbrev.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - /// - public partial class AddCustomerAbbrev : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "CustomerAbbrev", - table: "CmsInstances", - type: "TEXT", - maxLength: 3, - nullable: false, - defaultValue: ""); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "CustomerAbbrev", - table: "CmsInstances"); - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218143812_AddAppSettings.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260218143812_AddAppSettings.Designer.cs deleted file mode 100644 index 1f66b4e..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260218143812_AddAppSettings.Designer.cs +++ /dev/null @@ -1,323 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - [Migration("20260218143812_AddAppSettings")] - partial class AddAppSettings - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("TEXT"); - - b.Property("IsSensitive") - .HasColumnType("INTEGER"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasMaxLength(4000) - .HasColumnType("TEXT"); - - b.HasKey("Key"); - - b.HasIndex("Category"); - - b.ToTable("AppSettings"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CmsServerName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Constraints") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerAbbrev") - .IsRequired() - .HasMaxLength(3) - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("HostHttpPort") - .HasColumnType("INTEGER"); - - b.Property("LibraryHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("SmtpServer") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SmtpUsername") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SshHostId") - .HasColumnType("TEXT"); - - b.Property("StackName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("TemplateCacheKey") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("TemplateLastFetch") - .HasColumnType("TEXT"); - - b.Property("TemplateRepoPat") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("TemplateRepoUrl") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("ThemeHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("XiboApiTestStatus") - .HasColumnType("INTEGER"); - - b.Property("XiboApiTestedAt") - .HasColumnType("TEXT"); - - b.Property("XiboPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("XiboUsername") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CustomerName"); - - b.HasIndex("SshHostId"); - - b.HasIndex("StackName") - .IsUnique(); - - b.ToTable("CmsInstances"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("InstanceId") - .HasColumnType("TEXT"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("InstanceId"); - - b.HasIndex("Operation"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("IsGlobal") - .HasColumnType("INTEGER"); - - b.Property("LastRotatedAt") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("SecretMetadata"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost") - .WithMany("Instances") - .HasForeignKey("SshHostId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("SshHost"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance") - .WithMany("OperationLogs") - .HasForeignKey("InstanceId"); - - b.Navigation("Instance"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Navigation("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Navigation("Instances"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218143812_AddAppSettings.cs b/OTSSignsOrchestrator.Core/Migrations/20260218143812_AddAppSettings.cs deleted file mode 100644 index 33f6a11..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260218143812_AddAppSettings.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - /// - public partial class AddAppSettings : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "AppSettings", - columns: table => new - { - Key = table.Column(type: "TEXT", maxLength: 200, nullable: false), - Value = table.Column(type: "TEXT", maxLength: 4000, nullable: true), - Category = table.Column(type: "TEXT", maxLength: 50, nullable: false), - IsSensitive = table.Column(type: "INTEGER", nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AppSettings", x => x.Key); - }); - - migrationBuilder.CreateIndex( - name: "IX_AppSettings_Category", - table: "AppSettings", - column: "Category"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AppSettings"); - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218144537_AddPerInstanceCifsCredentials.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260218144537_AddPerInstanceCifsCredentials.Designer.cs deleted file mode 100644 index 44765bd..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260218144537_AddPerInstanceCifsCredentials.Designer.cs +++ /dev/null @@ -1,343 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - [Migration("20260218144537_AddPerInstanceCifsCredentials")] - partial class AddPerInstanceCifsCredentials - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("TEXT"); - - b.Property("IsSensitive") - .HasColumnType("INTEGER"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasMaxLength(4000) - .HasColumnType("TEXT"); - - b.HasKey("Key"); - - b.HasIndex("Category"); - - b.ToTable("AppSettings"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CifsExtraOptions") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("CifsPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("CifsServer") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("CifsShareBasePath") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("CifsUsername") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("CmsServerName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Constraints") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerAbbrev") - .IsRequired() - .HasMaxLength(3) - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("HostHttpPort") - .HasColumnType("INTEGER"); - - b.Property("LibraryHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("SmtpServer") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SmtpUsername") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SshHostId") - .HasColumnType("TEXT"); - - b.Property("StackName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("TemplateCacheKey") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("TemplateLastFetch") - .HasColumnType("TEXT"); - - b.Property("TemplateRepoPat") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("TemplateRepoUrl") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("ThemeHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("XiboApiTestStatus") - .HasColumnType("INTEGER"); - - b.Property("XiboApiTestedAt") - .HasColumnType("TEXT"); - - b.Property("XiboPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("XiboUsername") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CustomerName"); - - b.HasIndex("SshHostId"); - - b.HasIndex("StackName") - .IsUnique(); - - b.ToTable("CmsInstances"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("InstanceId") - .HasColumnType("TEXT"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("InstanceId"); - - b.HasIndex("Operation"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("IsGlobal") - .HasColumnType("INTEGER"); - - b.Property("LastRotatedAt") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("SecretMetadata"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost") - .WithMany("Instances") - .HasForeignKey("SshHostId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("SshHost"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance") - .WithMany("OperationLogs") - .HasForeignKey("InstanceId"); - - b.Navigation("Instance"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Navigation("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Navigation("Instances"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218144537_AddPerInstanceCifsCredentials.cs b/OTSSignsOrchestrator.Core/Migrations/20260218144537_AddPerInstanceCifsCredentials.cs deleted file mode 100644 index 7108a5c..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260218144537_AddPerInstanceCifsCredentials.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - /// - public partial class AddPerInstanceCifsCredentials : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "CifsExtraOptions", - table: "CmsInstances", - type: "TEXT", - maxLength: 500, - nullable: true); - - migrationBuilder.AddColumn( - name: "CifsPassword", - table: "CmsInstances", - type: "TEXT", - maxLength: 1000, - nullable: true); - - migrationBuilder.AddColumn( - name: "CifsServer", - table: "CmsInstances", - type: "TEXT", - maxLength: 200, - nullable: true); - - migrationBuilder.AddColumn( - name: "CifsShareBasePath", - table: "CmsInstances", - type: "TEXT", - maxLength: 500, - nullable: true); - - migrationBuilder.AddColumn( - name: "CifsUsername", - table: "CmsInstances", - type: "TEXT", - maxLength: 200, - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "CifsExtraOptions", - table: "CmsInstances"); - - migrationBuilder.DropColumn( - name: "CifsPassword", - table: "CmsInstances"); - - migrationBuilder.DropColumn( - name: "CifsServer", - table: "CmsInstances"); - - migrationBuilder.DropColumn( - name: "CifsShareBasePath", - table: "CmsInstances"); - - migrationBuilder.DropColumn( - name: "CifsUsername", - table: "CmsInstances"); - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.Designer.cs deleted file mode 100644 index 69c2455..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.Designer.cs +++ /dev/null @@ -1,343 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - [Migration("20260218180240_RenameShareBasePathToShareName")] - partial class RenameShareBasePathToShareName - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("TEXT"); - - b.Property("IsSensitive") - .HasColumnType("INTEGER"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasMaxLength(4000) - .HasColumnType("TEXT"); - - b.HasKey("Key"); - - b.HasIndex("Category"); - - b.ToTable("AppSettings"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CifsExtraOptions") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("CifsPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("CifsServer") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("CifsShareName") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("CifsUsername") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("CmsServerName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Constraints") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerAbbrev") - .IsRequired() - .HasMaxLength(3) - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("HostHttpPort") - .HasColumnType("INTEGER"); - - b.Property("LibraryHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("SmtpServer") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SmtpUsername") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SshHostId") - .HasColumnType("TEXT"); - - b.Property("StackName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("TemplateCacheKey") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("TemplateLastFetch") - .HasColumnType("TEXT"); - - b.Property("TemplateRepoPat") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("TemplateRepoUrl") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("ThemeHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("XiboApiTestStatus") - .HasColumnType("INTEGER"); - - b.Property("XiboApiTestedAt") - .HasColumnType("TEXT"); - - b.Property("XiboPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("XiboUsername") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CustomerName"); - - b.HasIndex("SshHostId"); - - b.HasIndex("StackName") - .IsUnique(); - - b.ToTable("CmsInstances"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("InstanceId") - .HasColumnType("TEXT"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("InstanceId"); - - b.HasIndex("Operation"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("IsGlobal") - .HasColumnType("INTEGER"); - - b.Property("LastRotatedAt") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("SecretMetadata"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost") - .WithMany("Instances") - .HasForeignKey("SshHostId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("SshHost"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance") - .WithMany("OperationLogs") - .HasForeignKey("InstanceId"); - - b.Navigation("Instance"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Navigation("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Navigation("Instances"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.cs b/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.cs deleted file mode 100644 index 9320ae1..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - /// - public partial class RenameShareBasePathToShareName : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "CifsShareBasePath", - table: "CmsInstances", - newName: "CifsShareName"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "CifsShareName", - table: "CmsInstances", - newName: "CifsShareBasePath"); - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.Designer.cs deleted file mode 100644 index a125238..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.Designer.cs +++ /dev/null @@ -1,347 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - [Migration("20260218202617_AddCifsShareFolder")] - partial class AddCifsShareFolder - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("TEXT"); - - b.Property("IsSensitive") - .HasColumnType("INTEGER"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasMaxLength(4000) - .HasColumnType("TEXT"); - - b.HasKey("Key"); - - b.HasIndex("Category"); - - b.ToTable("AppSettings"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CifsExtraOptions") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("CifsPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("CifsServer") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("CifsShareFolder") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("CifsShareName") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("CifsUsername") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("CmsServerName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Constraints") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerAbbrev") - .IsRequired() - .HasMaxLength(3) - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("HostHttpPort") - .HasColumnType("INTEGER"); - - b.Property("LibraryHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("SmtpServer") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SmtpUsername") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SshHostId") - .HasColumnType("TEXT"); - - b.Property("StackName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("TemplateCacheKey") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("TemplateLastFetch") - .HasColumnType("TEXT"); - - b.Property("TemplateRepoPat") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("TemplateRepoUrl") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("ThemeHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("XiboApiTestStatus") - .HasColumnType("INTEGER"); - - b.Property("XiboApiTestedAt") - .HasColumnType("TEXT"); - - b.Property("XiboPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("XiboUsername") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CustomerName"); - - b.HasIndex("SshHostId"); - - b.HasIndex("StackName") - .IsUnique(); - - b.ToTable("CmsInstances"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("InstanceId") - .HasColumnType("TEXT"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("InstanceId"); - - b.HasIndex("Operation"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("IsGlobal") - .HasColumnType("INTEGER"); - - b.Property("LastRotatedAt") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("SecretMetadata"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost") - .WithMany("Instances") - .HasForeignKey("SshHostId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("SshHost"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance") - .WithMany("OperationLogs") - .HasForeignKey("InstanceId"); - - b.Navigation("Instance"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Navigation("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Navigation("Instances"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.cs b/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.cs deleted file mode 100644 index f612631..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - /// - public partial class AddCifsShareFolder : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "CifsShareFolder", - table: "CmsInstances", - type: "TEXT", - maxLength: 500, - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "CifsShareFolder", - table: "CmsInstances"); - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.Designer.cs deleted file mode 100644 index 4cca5a8..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.Designer.cs +++ /dev/null @@ -1,339 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - [Migration("20260219005507_ReplaceCifsWithNfs")] - partial class ReplaceCifsWithNfs - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("TEXT"); - - b.Property("IsSensitive") - .HasColumnType("INTEGER"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasMaxLength(4000) - .HasColumnType("TEXT"); - - b.HasKey("Key"); - - b.HasIndex("Category"); - - b.ToTable("AppSettings"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CmsServerName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Constraints") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerAbbrev") - .IsRequired() - .HasMaxLength(3) - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("HostHttpPort") - .HasColumnType("INTEGER"); - - b.Property("LibraryHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("NfsExport") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("NfsExportFolder") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("NfsExtraOptions") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("NfsServer") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SmtpServer") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SmtpUsername") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SshHostId") - .HasColumnType("TEXT"); - - b.Property("StackName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("TemplateCacheKey") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("TemplateLastFetch") - .HasColumnType("TEXT"); - - b.Property("TemplateRepoPat") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("TemplateRepoUrl") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("ThemeHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("XiboApiTestStatus") - .HasColumnType("INTEGER"); - - b.Property("XiboApiTestedAt") - .HasColumnType("TEXT"); - - b.Property("XiboPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("XiboUsername") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CustomerName"); - - b.HasIndex("SshHostId"); - - b.HasIndex("StackName") - .IsUnique(); - - b.ToTable("CmsInstances"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("InstanceId") - .HasColumnType("TEXT"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("InstanceId"); - - b.HasIndex("Operation"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("IsGlobal") - .HasColumnType("INTEGER"); - - b.Property("LastRotatedAt") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("SecretMetadata"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost") - .WithMany("Instances") - .HasForeignKey("SshHostId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("SshHost"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance") - .WithMany("OperationLogs") - .HasForeignKey("InstanceId"); - - b.Navigation("Instance"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Navigation("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Navigation("Instances"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.cs b/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.cs deleted file mode 100644 index 5528f95..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - /// - public partial class ReplaceCifsWithNfs : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // 1. Add new NFS columns - migrationBuilder.AddColumn( - name: "NfsServer", - table: "CmsInstances", - type: "TEXT", - maxLength: 200, - nullable: true); - - migrationBuilder.AddColumn( - name: "NfsExport", - table: "CmsInstances", - type: "TEXT", - maxLength: 500, - nullable: true); - - migrationBuilder.AddColumn( - name: "NfsExportFolder", - table: "CmsInstances", - type: "TEXT", - maxLength: 500, - nullable: true); - - migrationBuilder.AddColumn( - name: "NfsExtraOptions", - table: "CmsInstances", - type: "TEXT", - maxLength: 500, - nullable: true); - - // 2. Migrate existing CIFS data into NFS columns - // NfsServer = CifsServer, NfsExport = '/' + CifsShareName, NfsExportFolder = CifsShareFolder - migrationBuilder.Sql( - """ - UPDATE CmsInstances - SET NfsServer = CifsServer, - NfsExport = CASE WHEN CifsShareName IS NOT NULL THEN '/' || CifsShareName ELSE NULL END, - NfsExportFolder = CifsShareFolder, - NfsExtraOptions = CifsExtraOptions - WHERE CifsServer IS NOT NULL; - """); - - // 3. Drop old CIFS columns - migrationBuilder.DropColumn(name: "CifsServer", table: "CmsInstances"); - migrationBuilder.DropColumn(name: "CifsShareName", table: "CmsInstances"); - migrationBuilder.DropColumn(name: "CifsShareFolder", table: "CmsInstances"); - migrationBuilder.DropColumn(name: "CifsUsername", table: "CmsInstances"); - migrationBuilder.DropColumn(name: "CifsPassword", table: "CmsInstances"); - migrationBuilder.DropColumn(name: "CifsExtraOptions", table: "CmsInstances"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - // Re-add CIFS columns - migrationBuilder.AddColumn( - name: "CifsServer", table: "CmsInstances", type: "TEXT", maxLength: 200, nullable: true); - migrationBuilder.AddColumn( - name: "CifsShareName", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true); - migrationBuilder.AddColumn( - name: "CifsShareFolder", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true); - migrationBuilder.AddColumn( - name: "CifsUsername", table: "CmsInstances", type: "TEXT", maxLength: 200, nullable: true); - migrationBuilder.AddColumn( - name: "CifsPassword", table: "CmsInstances", type: "TEXT", maxLength: 1000, nullable: true); - migrationBuilder.AddColumn( - name: "CifsExtraOptions", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true); - - // Copy NFS data back to CIFS columns - migrationBuilder.Sql( - """ - UPDATE CmsInstances - SET CifsServer = NfsServer, - CifsShareName = CASE WHEN NfsExport IS NOT NULL THEN LTRIM(NfsExport, '/') ELSE NULL END, - CifsShareFolder = NfsExportFolder, - CifsExtraOptions = NfsExtraOptions - WHERE NfsServer IS NOT NULL; - """); - - // Drop NFS columns - migrationBuilder.DropColumn(name: "NfsServer", table: "CmsInstances"); - migrationBuilder.DropColumn(name: "NfsExport", table: "CmsInstances"); - migrationBuilder.DropColumn(name: "NfsExportFolder", table: "CmsInstances"); - migrationBuilder.DropColumn(name: "NfsExtraOptions", table: "CmsInstances"); - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.Designer.cs deleted file mode 100644 index b9a66ff..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.Designer.cs +++ /dev/null @@ -1,347 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - [Migration("20260219020727_AddNewtCredentials")] - partial class AddNewtCredentials - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("TEXT"); - - b.Property("IsSensitive") - .HasColumnType("INTEGER"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasMaxLength(4000) - .HasColumnType("TEXT"); - - b.HasKey("Key"); - - b.HasIndex("Category"); - - b.ToTable("AppSettings"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CmsServerName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Constraints") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerAbbrev") - .IsRequired() - .HasMaxLength(3) - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("HostHttpPort") - .HasColumnType("INTEGER"); - - b.Property("LibraryHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("NewtId") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("NewtSecret") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("NfsExport") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("NfsExportFolder") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("NfsExtraOptions") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("NfsServer") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SmtpServer") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SmtpUsername") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("SshHostId") - .HasColumnType("TEXT"); - - b.Property("StackName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("TemplateCacheKey") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("TemplateLastFetch") - .HasColumnType("TEXT"); - - b.Property("TemplateRepoPat") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("TemplateRepoUrl") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("ThemeHostPath") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("XiboApiTestStatus") - .HasColumnType("INTEGER"); - - b.Property("XiboApiTestedAt") - .HasColumnType("TEXT"); - - b.Property("XiboPassword") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("XiboUsername") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CustomerName"); - - b.HasIndex("SshHostId"); - - b.HasIndex("StackName") - .IsUnique(); - - b.ToTable("CmsInstances"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("InstanceId") - .HasColumnType("TEXT"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("InstanceId"); - - b.HasIndex("Operation"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CustomerName") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("IsGlobal") - .HasColumnType("INTEGER"); - - b.Property("LastRotatedAt") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("SecretMetadata"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost") - .WithMany("Instances") - .HasForeignKey("SshHostId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("SshHost"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance") - .WithMany("OperationLogs") - .HasForeignKey("InstanceId"); - - b.Navigation("Instance"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => - { - b.Navigation("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Navigation("Instances"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.cs b/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.cs deleted file mode 100644 index ad0d105..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - /// - public partial class AddNewtCredentials : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "NewtId", - table: "CmsInstances", - type: "TEXT", - maxLength: 500, - nullable: true); - - migrationBuilder.AddColumn( - name: "NewtSecret", - table: "CmsInstances", - type: "TEXT", - maxLength: 500, - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "NewtId", - table: "CmsInstances"); - - migrationBuilder.DropColumn( - name: "NewtSecret", - table: "CmsInstances"); - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.Designer.cs deleted file mode 100644 index ea5f1fc..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.Designer.cs +++ /dev/null @@ -1,153 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - [Migration("20260219121529_RemoveCmsInstancesAndSecretMetadata")] - partial class RemoveCmsInstancesAndSecretMetadata - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("TEXT"); - - b.Property("IsSensitive") - .HasColumnType("INTEGER"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasMaxLength(4000) - .HasColumnType("TEXT"); - - b.HasKey("Key"); - - b.HasIndex("Category"); - - b.ToTable("AppSettings"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("StackName") - .HasMaxLength(150) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Operation"); - - b.HasIndex("StackName"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.cs b/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.cs deleted file mode 100644 index 46b147e..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - /// - public partial class RemoveCmsInstancesAndSecretMetadata : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_OperationLogs_CmsInstances_InstanceId", - table: "OperationLogs"); - - migrationBuilder.DropTable( - name: "CmsInstances"); - - migrationBuilder.DropTable( - name: "SecretMetadata"); - - migrationBuilder.DropIndex( - name: "IX_OperationLogs_InstanceId", - table: "OperationLogs"); - - migrationBuilder.DropColumn( - name: "InstanceId", - table: "OperationLogs"); - - migrationBuilder.AddColumn( - name: "StackName", - table: "OperationLogs", - type: "TEXT", - maxLength: 150, - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_OperationLogs_StackName", - table: "OperationLogs", - column: "StackName"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_OperationLogs_StackName", - table: "OperationLogs"); - - migrationBuilder.DropColumn( - name: "StackName", - table: "OperationLogs"); - - migrationBuilder.AddColumn( - name: "InstanceId", - table: "OperationLogs", - type: "TEXT", - nullable: true); - - migrationBuilder.CreateTable( - name: "CmsInstances", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - SshHostId = table.Column(type: "TEXT", nullable: true), - CmsServerName = table.Column(type: "TEXT", maxLength: 200, nullable: false), - Constraints = table.Column(type: "TEXT", maxLength: 2000, nullable: true), - CreatedAt = table.Column(type: "TEXT", nullable: false), - CustomerAbbrev = table.Column(type: "TEXT", maxLength: 3, nullable: false), - CustomerName = table.Column(type: "TEXT", maxLength: 100, nullable: false), - DeletedAt = table.Column(type: "TEXT", nullable: true), - HostHttpPort = table.Column(type: "INTEGER", nullable: false), - LibraryHostPath = table.Column(type: "TEXT", maxLength: 500, nullable: false), - NewtId = table.Column(type: "TEXT", maxLength: 500, nullable: true), - NewtSecret = table.Column(type: "TEXT", maxLength: 500, nullable: true), - NfsExport = table.Column(type: "TEXT", maxLength: 500, nullable: true), - NfsExportFolder = table.Column(type: "TEXT", maxLength: 500, nullable: true), - NfsExtraOptions = table.Column(type: "TEXT", maxLength: 500, nullable: true), - NfsServer = table.Column(type: "TEXT", maxLength: 200, nullable: true), - SmtpServer = table.Column(type: "TEXT", maxLength: 200, nullable: false), - SmtpUsername = table.Column(type: "TEXT", maxLength: 200, nullable: false), - StackName = table.Column(type: "TEXT", maxLength: 100, nullable: false), - Status = table.Column(type: "INTEGER", nullable: false), - TemplateCacheKey = table.Column(type: "TEXT", maxLength: 100, nullable: true), - TemplateLastFetch = table.Column(type: "TEXT", nullable: true), - TemplateRepoPat = table.Column(type: "TEXT", maxLength: 500, nullable: true), - TemplateRepoUrl = table.Column(type: "TEXT", maxLength: 500, nullable: false), - ThemeHostPath = table.Column(type: "TEXT", maxLength: 500, nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: false), - XiboApiTestStatus = table.Column(type: "INTEGER", nullable: false), - XiboApiTestedAt = table.Column(type: "TEXT", nullable: true), - XiboPassword = table.Column(type: "TEXT", maxLength: 1000, nullable: true), - XiboUsername = table.Column(type: "TEXT", maxLength: 500, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_CmsInstances", x => x.Id); - table.ForeignKey( - name: "FK_CmsInstances_SshHosts_SshHostId", - column: x => x.SshHostId, - principalTable: "SshHosts", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - }); - - migrationBuilder.CreateTable( - name: "SecretMetadata", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - CreatedAt = table.Column(type: "TEXT", nullable: false), - CustomerName = table.Column(type: "TEXT", maxLength: 100, nullable: true), - IsGlobal = table.Column(type: "INTEGER", nullable: false), - LastRotatedAt = table.Column(type: "TEXT", nullable: true), - Name = table.Column(type: "TEXT", maxLength: 200, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_SecretMetadata", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_OperationLogs_InstanceId", - table: "OperationLogs", - column: "InstanceId"); - - migrationBuilder.CreateIndex( - name: "IX_CmsInstances_CustomerName", - table: "CmsInstances", - column: "CustomerName"); - - migrationBuilder.CreateIndex( - name: "IX_CmsInstances_SshHostId", - table: "CmsInstances", - column: "SshHostId"); - - migrationBuilder.CreateIndex( - name: "IX_CmsInstances_StackName", - table: "CmsInstances", - column: "StackName", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_SecretMetadata_Name", - table: "SecretMetadata", - column: "Name", - unique: true); - - migrationBuilder.AddForeignKey( - name: "FK_OperationLogs_CmsInstances_InstanceId", - table: "OperationLogs", - column: "InstanceId", - principalTable: "CmsInstances", - principalColumn: "Id"); - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.Designer.cs deleted file mode 100644 index 392ea49..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.Designer.cs +++ /dev/null @@ -1,125 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - [Migration("20260225135644_DropAppSettings")] - partial class DropAppSettings - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("StackName") - .HasMaxLength(150) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Operation"); - - b.HasIndex("StackName"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.cs b/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.cs deleted file mode 100644 index 8048481..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - /// - public partial class DropAppSettings : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AppSettings"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "AppSettings", - columns: table => new - { - Key = table.Column(type: "TEXT", maxLength: 200, nullable: false), - Category = table.Column(type: "TEXT", maxLength: 50, nullable: false), - IsSensitive = table.Column(type: "INTEGER", nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: false), - Value = table.Column(type: "TEXT", maxLength: 4000, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AppSettings", x => x.Key); - }); - - migrationBuilder.CreateIndex( - name: "IX_AppSettings_Category", - table: "AppSettings", - column: "Category"); - } - } -} diff --git a/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs b/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs deleted file mode 100644 index 011fcef..0000000 --- a/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs +++ /dev/null @@ -1,122 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using OTSSignsOrchestrator.Core.Data; - -#nullable disable - -namespace OTSSignsOrchestrator.Core.Migrations -{ - [DbContext(typeof(XiboContext))] - partial class XiboContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("DurationMs") - .HasColumnType("INTEGER"); - - b.Property("Message") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Operation") - .HasColumnType("INTEGER"); - - b.Property("StackName") - .HasMaxLength(150) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Operation"); - - b.HasIndex("StackName"); - - b.HasIndex("Timestamp"); - - b.ToTable("OperationLogs"); - }); - - modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("KeyPassphrase") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("LastTestSuccess") - .HasColumnType("INTEGER"); - - b.Property("LastTestedAt") - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("PrivateKeyPath") - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UseKeyAuth") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Label") - .IsUnique(); - - b.ToTable("SshHosts"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/OTSSignsOrchestrator.Core/Models/Entities/AppSetting.cs b/OTSSignsOrchestrator.Core/Models/Entities/AppSetting.cs deleted file mode 100644 index ddffcf8..0000000 --- a/OTSSignsOrchestrator.Core/Models/Entities/AppSetting.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace OTSSignsOrchestrator.Core.Models.Entities; - -/// -/// Key-value application setting persisted in the local database. -/// Sensitive values are encrypted at rest via DataProtection. -/// -public class AppSetting -{ - [Key, MaxLength(200)] - public string Key { get; set; } = string.Empty; - - [MaxLength(4000)] - public string? Value { get; set; } - - [Required, MaxLength(50)] - public string Category { get; set; } = string.Empty; - - public bool IsSensitive { get; set; } - - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; -} diff --git a/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj b/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj deleted file mode 100644 index d9241c1..0000000 --- a/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - diff --git a/OTSSignsOrchestrator.Core/Services/IInvitationSetupService.cs b/OTSSignsOrchestrator.Core/Services/IInvitationSetupService.cs deleted file mode 100644 index 0bf191f..0000000 --- a/OTSSignsOrchestrator.Core/Services/IInvitationSetupService.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace OTSSignsOrchestrator.Core.Services; - -/// -/// Orchestrates the complete Authentik invitation infrastructure setup for a customer. -/// Creates a group, enrollment flow with stages, role with invitation permissions, -/// and scoping policies so the customer admin can invite new users without OTS involvement. -/// -public interface IInvitationSetupService -{ - /// - /// Sets up the full invitation infrastructure for a customer in Authentik: - /// - /// Create customer group (e.g. customer-acme). - /// Create invitation stage (invite-only, no anonymous enrollment). - /// Create enrollment flow with stages: Invitation → Prompt → UserWrite → UserLogin. - /// Bind expression policy to UserWrite stage to auto-assign users to the customer group. - /// Create invite-manager role with invitation CRUD permissions. - /// Assign role to customer group and bind scoping policy to flow. - /// - /// All operations are idempotent — safe to call multiple times for the same customer. - /// - /// Short customer identifier (e.g. "acme"). - /// Human-readable customer name (e.g. "Acme Corp"). - /// Cancellation token. - /// Result describing what was created and the enrollment flow URL. - Task SetupCustomerInvitationAsync( - string customerAbbrev, - string customerName, - CancellationToken ct = default); -} - -/// -/// Result of the invitation infrastructure setup. -/// -public class InvitationSetupResult -{ - /// Whether the setup completed successfully. - public bool Success { get; set; } - - /// Human-readable status message. - public string Message { get; set; } = string.Empty; - - /// Name of the customer group created in Authentik. - public string GroupName { get; set; } = string.Empty; - - /// Slug of the enrollment flow (used in invite links). - public string EnrollmentFlowSlug { get; set; } = string.Empty; - - /// Name of the role created for invitation management. - public string RoleName { get; set; } = string.Empty; - - /// - /// Full URL to the Authentik user portal where the customer admin - /// can manage invitations. - /// - public string? InvitationManagementUrl { get; set; } -} diff --git a/OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs b/OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs deleted file mode 100644 index 5825e24..0000000 --- a/OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs +++ /dev/null @@ -1,230 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace OTSSignsOrchestrator.Core.Services; - -/// -/// Orchestrates the 6-step Authentik invitation infrastructure setup for a customer. -/// All operations are idempotent — the underlying Authentik API methods check for -/// existing resources before creating new ones. -/// -/// Per customer, this creates: -/// 1. A group (customer-{abbrev}) -/// 2. An invitation stage ({abbrev}-invitation-stage) -/// 3. An enrollment flow ({abbrev}-enrollment) with stages bound in order -/// 4. An expression policy on the UserWrite stage to auto-assign users to the group -/// 5. A role ({abbrev}-invite-manager) with invitation CRUD permissions -/// 6. A scoping policy on the flow so only group members can access it -/// -public class InvitationSetupService : IInvitationSetupService -{ - private readonly IAuthentikService _authentik; - private readonly SettingsService _settings; - private readonly ILogger _logger; - - public InvitationSetupService( - IAuthentikService authentik, - SettingsService settings, - ILogger logger) - { - _authentik = authentik; - _settings = settings; - _logger = logger; - } - - /// - public async Task SetupCustomerInvitationAsync( - string customerAbbrev, - string customerName, - CancellationToken ct = default) - { - var abbrev = customerAbbrev.Trim().ToLowerInvariant(); - var groupName = $"customer-{abbrev}"; - var flowSlug = $"{abbrev}-enrollment"; - var flowName = $"{customerName} Enrollment"; - var roleName = $"{abbrev}-invite-manager"; - var invitationStageName = $"{abbrev}-invitation-stage"; - - _logger.LogInformation( - "[InviteSetup] Starting invitation infrastructure setup for customer '{Customer}' (abbrev={Abbrev})", - customerName, abbrev); - - try - { - // ═══════════════════════════════════════════════════════════════════ - // Step 1: Create customer group - // ═══════════════════════════════════════════════════════════════════ - _logger.LogInformation("[InviteSetup] Step 1/6: Creating customer group '{Group}'", groupName); - var groupPk = await _authentik.CreateGroupAsync(groupName, ct); - - // ═══════════════════════════════════════════════════════════════════ - // Step 2: Create invitation stage - // ═══════════════════════════════════════════════════════════════════ - _logger.LogInformation("[InviteSetup] Step 2/6: Creating invitation stage '{Stage}'", invitationStageName); - var invitationStagePk = await _authentik.CreateInvitationStageAsync( - invitationStageName, continueWithoutInvitation: false, ct); - - // ═══════════════════════════════════════════════════════════════════ - // Step 3: Create enrollment flow and bind stages - // ═══════════════════════════════════════════════════════════════════ - _logger.LogInformation("[InviteSetup] Step 3/6: Creating enrollment flow '{Slug}'", flowSlug); - var flowPk = await _authentik.CreateEnrollmentFlowAsync(flowName, flowSlug, ct); - - // Resolve built-in Authentik stages for the enrollment pipeline - var promptStagePk = await _authentik.FindStageByNameAsync("default-enrollment-prompt", ct); - var userWriteStagePk = await _authentik.FindStageByNameAsync("default-enrollment-user-write", ct); - var userLoginStagePk = await _authentik.FindStageByNameAsync("default-enrollment-user-login", ct); - - // Bind stages in order: 10=Invitation, 20=Prompt, 30=UserWrite, 40=UserLogin - await _authentik.BindStageToFlowAsync(flowSlug, invitationStagePk, 10, ct); - - if (promptStagePk != null) - { - await _authentik.BindStageToFlowAsync(flowSlug, promptStagePk, 20, ct); - _logger.LogInformation("[InviteSetup] Bound default prompt stage at order 20"); - } - else - { - _logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-prompt' stage not found — " + - "you may need to create a prompt stage manually and bind it to flow '{Slug}' at order 20", flowSlug); - } - - if (userWriteStagePk != null) - { - await _authentik.BindStageToFlowAsync(flowSlug, userWriteStagePk, 30, ct); - _logger.LogInformation("[InviteSetup] Bound default user-write stage at order 30"); - } - else - { - _logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-user-write' stage not found — " + - "you may need to create a user-write stage manually and bind it to flow '{Slug}' at order 30", flowSlug); - } - - if (userLoginStagePk != null) - { - await _authentik.BindStageToFlowAsync(flowSlug, userLoginStagePk, 40, ct); - _logger.LogInformation("[InviteSetup] Bound default user-login stage at order 40"); - } - else - { - _logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-user-login' stage not found — " + - "you may need to create a user-login stage manually and bind it to flow '{Slug}' at order 40", flowSlug); - } - - // ═══════════════════════════════════════════════════════════════════ - // Step 4: Create group-assignment policy and bind to UserWrite stage - // ═══════════════════════════════════════════════════════════════════ - _logger.LogInformation("[InviteSetup] Step 4/6: Creating group-assignment expression policy"); - - var groupAssignPolicyName = $"{abbrev}-auto-assign-group"; - var groupAssignExpression = $""" - from authentik.core.models import Group - - # Auto-assign to customer group on registration - group = Group.objects.filter(name="{groupName}").first() - if group and context.get("pending_user"): - context["pending_user"].ak_groups.add(group) - - return True - """; - - var groupAssignPolicyPk = await _authentik.CreateExpressionPolicyAsync( - groupAssignPolicyName, groupAssignExpression, ct); - - // Bind policy to the UserWrite stage binding (order 30) - if (userWriteStagePk != null) - { - var userWriteBindingPk = await _authentik.GetFlowStageBindingPkAsync(flowSlug, 30, ct); - if (userWriteBindingPk != null) - { - await _authentik.BindPolicyToFlowStageBoundAsync(userWriteBindingPk, groupAssignPolicyPk, ct); - _logger.LogInformation("[InviteSetup] Group-assignment policy bound to UserWrite stage"); - } - else - { - _logger.LogWarning("[InviteSetup] Could not find flow-stage binding at order 30 to attach group policy"); - } - } - - // ═══════════════════════════════════════════════════════════════════ - // Step 5: Create invite-manager role with permissions - // ═══════════════════════════════════════════════════════════════════ - _logger.LogInformation("[InviteSetup] Step 5/6: Creating invite-manager role '{Role}'", roleName); - var rolePk = await _authentik.CreateRoleAsync(roleName, ct); - - // Assign invitation CRUD permissions - var invitationPermissions = new[] - { - "add_invitation", - "view_invitation", - "delete_invitation", - }; - await _authentik.AssignPermissionsToRoleAsync(rolePk, invitationPermissions, ct); - - // Assign role to the customer group - await _authentik.AssignRoleToGroupAsync(rolePk, groupPk, ct); - _logger.LogInformation("[InviteSetup] Role '{Role}' assigned to group '{Group}'", roleName, groupName); - - // ═══════════════════════════════════════════════════════════════════ - // Step 6: Create scoping policy and bind to flow - // ═══════════════════════════════════════════════════════════════════ - _logger.LogInformation("[InviteSetup] Step 6/6: Creating invitation scoping policy"); - - var scopePolicyName = $"scope-invitations-to-{abbrev}"; - var scopeExpression = $""" - # Only allow users in {groupName} group to manage these invitations - user = context.get("pending_user") or request.user - - if user.ak_groups.filter(name="{groupName}").exists(): - return True - - return False - """; - - var scopePolicyPk = await _authentik.CreateExpressionPolicyAsync(scopePolicyName, scopeExpression, ct); - - // Bind scoping policy to the enrollment flow - await _authentik.BindPolicyToFlowAsync(flowSlug, scopePolicyPk, ct); - _logger.LogInformation("[InviteSetup] Scoping policy bound to enrollment flow"); - - // ═══════════════════════════════════════════════════════════════════ - // Build result with management URL - // ═══════════════════════════════════════════════════════════════════ - var authentikUrl = await _settings.GetAsync(SettingsService.AuthentikUrl); - var managementUrl = !string.IsNullOrWhiteSpace(authentikUrl) - ? $"{authentikUrl.TrimEnd('/')}/if/admin/#/core/invitations" - : null; - - var result = new InvitationSetupResult - { - Success = true, - Message = $"Invitation infrastructure created for {customerName}. " + - $"Group: {groupName}, Flow: {flowSlug}, Role: {roleName}.", - GroupName = groupName, - EnrollmentFlowSlug = flowSlug, - RoleName = roleName, - InvitationManagementUrl = managementUrl, - }; - - _logger.LogInformation( - "[InviteSetup] Setup complete for '{Customer}': group={Group}, flow={Flow}, role={Role}, url={Url}", - customerName, groupName, flowSlug, roleName, managementUrl ?? "(no URL)"); - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, - "[InviteSetup] Invitation setup failed for '{Customer}' (abbrev={Abbrev}): {Message}", - customerName, abbrev, ex.Message); - - return new InvitationSetupResult - { - Success = false, - Message = $"Invitation setup failed: {ex.Message}", - GroupName = groupName, - EnrollmentFlowSlug = flowSlug, - RoleName = roleName, - }; - } - } -} diff --git a/OTSSignsOrchestrator.Desktop/App.axaml b/OTSSignsOrchestrator.Desktop/App.axaml deleted file mode 100644 index 095d24d..0000000 --- a/OTSSignsOrchestrator.Desktop/App.axaml +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - #D93A00 - #E54B14 - #BF3300 - #2A1A14 - - #0C0C14 - #11111B - #181825 - #1E1E2E - #232336 - #2A2A40 - - #CDD6F4 - #A6ADC8 - #6C7086 - - #4ADE80 - #60A5FA - #C084FC - #F472B6 - #FBBF24 - #2DD4BF - #F87171 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/OTSSignsOrchestrator.Desktop/App.axaml.cs b/OTSSignsOrchestrator.Desktop/App.axaml.cs deleted file mode 100644 index 6d42de6..0000000 --- a/OTSSignsOrchestrator.Desktop/App.axaml.cs +++ /dev/null @@ -1,208 +0,0 @@ -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Markup.Xaml; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Polly; -using Polly.Extensions.Http; -using Refit; -using Serilog; -using OTSSignsOrchestrator.Core.Configuration; -using OTSSignsOrchestrator.Core.Data; -using OTSSignsOrchestrator.Core.Services; -using OTSSignsOrchestrator.Desktop.Services; -using OTSSignsOrchestrator.Desktop.ViewModels; -using OTSSignsOrchestrator.Desktop.Views; - -namespace OTSSignsOrchestrator.Desktop; - -public class App : Application -{ - public static IServiceProvider Services { get; private set; } = null!; - - public override void Initialize() - { - AvaloniaXamlLoader.Load(this); - } - - public override void OnFrameworkInitializationCompleted() - { - var services = new ServiceCollection(); - ConfigureServices(services); - Services = services.BuildServiceProvider(); - - // Apply migrations - using (var scope = Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); - } - - Log.Information("ApplicationLifetime type: {Type}", ApplicationLifetime?.GetType().FullName ?? "null"); - - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - Log.Information("Creating MainWindow..."); - - // Import existing instance secrets from Bitwarden (fire-and-forget, non-blocking) - _ = Task.Run(async () => - { - try - { - // Pre-load config settings from Bitwarden so they're available immediately - using var scope = Services.CreateScope(); - var settings = scope.ServiceProvider.GetRequiredService(); - await settings.PreloadCacheAsync(); - Log.Information("Bitwarden config settings pre-loaded"); - - // Import existing instance secrets that aren't yet tracked - var postInit = Services.GetRequiredService(); - await postInit.ImportExistingInstanceSecretsAsync(); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to pre-load settings or import instance secrets on startup"); - } - }); - - var vm = Services.GetRequiredService(); - Log.Information("MainWindowViewModel resolved"); - - var window = new MainWindow - { - DataContext = vm - }; - - desktop.MainWindow = window; - Log.Information("MainWindow assigned to lifetime"); - - window.Show(); - window.Activate(); - Log.Information("MainWindow Show() + Activate() called"); - - // Start the SignalR connection (fire-and-forget, reconnect handles failures) - _ = Task.Run(async () => - { - try - { - var signalR = Services.GetRequiredService(); - await signalR.StartAsync(); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to start SignalR connection on startup"); - } - }); - - desktop.ShutdownRequested += (_, _) => - { - var ssh = Services.GetService(); - ssh?.Dispose(); - var signalR = Services.GetService(); - signalR?.DisposeAsync().AsTask().GetAwaiter().GetResult(); - }; - } - else - { - Log.Warning("ApplicationLifetime is NOT IClassicDesktopStyleApplicationLifetime — window cannot be shown"); - } - - base.OnFrameworkInitializationCompleted(); - } - - private static void ConfigureServices(IServiceCollection services) - { - // Configuration (reloadOnChange so runtime writes to appsettings.json are picked up) - var config = new ConfigurationBuilder() - .SetBasePath(AppContext.BaseDirectory) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .Build(); - - services.AddSingleton(config); - - // Options - services.Configure(config.GetSection(GitOptions.SectionName)); - services.Configure(config.GetSection(DockerOptions.SectionName)); - services.Configure(config.GetSection(XiboOptions.SectionName)); - services.Configure(config.GetSection(DatabaseOptions.SectionName)); - services.Configure(config.GetSection(FileLoggingOptions.SectionName)); - services.Configure(config.GetSection(BitwardenOptions.SectionName)); - - // Logging - services.AddLogging(builder => - { - builder.ClearProviders(); - builder.AddSerilog(dispose: true); - }); - - // Data Protection - var keysDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "OTSSignsOrchestrator", "keys"); - Directory.CreateDirectory(keysDir); - - services.AddDataProtection() - .PersistKeysToFileSystem(new DirectoryInfo(keysDir)) - .SetApplicationName("OTSSignsOrchestrator"); - - // Database - var connStr = config.GetConnectionString("Default") ?? "Data Source=otssigns-desktop.db"; - services.AddDbContext(options => options.UseSqlite(connStr)); - - // HTTP - services.AddHttpClient(); - services.AddHttpClient("XiboApi"); - services.AddHttpClient("XiboHealth"); - services.AddHttpClient("AuthentikApi"); - - // ── Server API integration ────────────────────────────────────────── - services.AddSingleton(); - services.AddTransient(); - - var serverBaseUrl = config["ServerBaseUrl"] ?? "https://localhost:5001"; - services.AddRefitClient() - .ConfigureHttpClient(c => c.BaseAddress = new Uri(serverBaseUrl)) - .AddHttpMessageHandler() - .AddPolicyHandler(HttpPolicyExtensions - .HandleTransientHttpError() - .WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)))); - - services.AddSingleton(); - - // SSH services (singletons — maintain connections) - services.AddSingleton(); - - // Docker services via SSH (singletons — SetHost() must persist across scopes) - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); - - // Core services - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddSingleton(); - - // ViewModels - services.AddSingleton(); // singleton: one main window, nav state shared - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - } -} diff --git a/OTSSignsOrchestrator.Desktop/Assets/OTS-Signs.png b/OTSSignsOrchestrator.Desktop/Assets/OTS-Signs.png deleted file mode 100644 index 5adc220..0000000 Binary files a/OTSSignsOrchestrator.Desktop/Assets/OTS-Signs.png and /dev/null differ diff --git a/OTSSignsOrchestrator.Desktop/Models/LiveStackItem.cs b/OTSSignsOrchestrator.Desktop/Models/LiveStackItem.cs deleted file mode 100644 index f78e7fa..0000000 --- a/OTSSignsOrchestrator.Desktop/Models/LiveStackItem.cs +++ /dev/null @@ -1,31 +0,0 @@ -using OTSSignsOrchestrator.Core.Models.Entities; - -namespace OTSSignsOrchestrator.Desktop.Models; - -/// -/// Represents a CMS stack discovered live from a Docker Swarm host. -/// No data is persisted locally — all values come from docker stack ls / inspect. -/// -public class LiveStackItem -{ - /// Docker stack name, e.g. "acm-cms-stack". - public string StackName { get; set; } = string.Empty; - - /// 3-letter abbreviation derived from the stack name. - public string CustomerAbbrev { get; set; } = string.Empty; - - /// Number of services reported by docker stack ls. - public int ServiceCount { get; set; } - - /// The SSH host this stack was found on. - public SshHost Host { get; set; } = null!; - - /// Label of the host — convenience property for data-binding. - public string HostLabel => Host?.Label ?? string.Empty; - - /// - /// Server-side customer ID. Populated when fleet data is loaded from the server API. - /// Null when loaded only from local Docker discovery. - /// - public Guid? CustomerId { get; set; } -} diff --git a/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj b/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj deleted file mode 100644 index c75f07e..0000000 --- a/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj +++ /dev/null @@ -1,62 +0,0 @@ - - - - WinExe - net9.0 - enable - enable - true - app.manifest - true - - linux-x64;win-x64;osx-x64;osx-arm64 - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - - - PreserveNewest - templates/settings-custom.php.template - - - - diff --git a/OTSSignsOrchestrator.Desktop/Program.cs b/OTSSignsOrchestrator.Desktop/Program.cs deleted file mode 100644 index 177478e..0000000 --- a/OTSSignsOrchestrator.Desktop/Program.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Avalonia; -using Avalonia.ReactiveUI; -using Microsoft.Extensions.DependencyInjection; -using Serilog; -using System; - -namespace OTSSignsOrchestrator.Desktop; - -sealed class Program -{ - [STAThread] - public static void Main(string[] args) - { - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Debug() - .WriteTo.Console() - .WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7) - .CreateLogger(); - - try - { - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); - } - catch (Exception ex) - { - Log.Fatal(ex, "Application terminated unexpectedly"); - } - finally - { - Log.CloseAndFlush(); - } - } - - public static AppBuilder BuildAvaloniaApp() - => AppBuilder.Configure() - .UsePlatformDetect() - .WithInterFont() - .LogToTrace() - .UseReactiveUI(); -} diff --git a/OTSSignsOrchestrator.Desktop/Services/IServerApiClient.cs b/OTSSignsOrchestrator.Desktop/Services/IServerApiClient.cs deleted file mode 100644 index 3b2cee8..0000000 --- a/OTSSignsOrchestrator.Desktop/Services/IServerApiClient.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Refit; - -namespace OTSSignsOrchestrator.Desktop.Services; - -// ── DTOs matching server REST API responses ───────────────────────────────── - -public record FleetSummaryDto -{ - public Guid CustomerId { get; init; } - public string Abbreviation { get; init; } = string.Empty; - public string CompanyName { get; init; } = string.Empty; - public string Plan { get; init; } = string.Empty; - public int ScreenCount { get; init; } - public string HealthStatus { get; init; } = "Unknown"; - public DateTime? LastHealthCheck { get; init; } - public bool HasRunningJob { get; init; } -} - -public record CustomerDetailDto -{ - public Guid Id { get; init; } - public string Abbreviation { get; init; } = string.Empty; - public string CompanyName { get; init; } = string.Empty; - public string? AdminEmail { get; init; } - public string Plan { get; init; } = string.Empty; - public int ScreenCount { get; init; } - public string Status { get; init; } = string.Empty; - public DateTime CreatedAt { get; init; } - public List Instances { get; init; } = []; - public List ActiveJobs { get; init; } = []; -} - -public record CustomerInstanceDto -{ - public Guid Id { get; init; } - public string? XiboUrl { get; init; } - public string? DockerStackName { get; init; } - public string HealthStatus { get; init; } = "Unknown"; - public DateTime? LastHealthCheck { get; init; } -} - -public record CustomerJobDto -{ - public Guid Id { get; init; } - public string JobType { get; init; } = string.Empty; - public string Status { get; init; } = string.Empty; - public DateTime CreatedAt { get; init; } - public DateTime? StartedAt { get; init; } -} - -public record CreateJobRequest(Guid CustomerId, string JobType, string? Parameters); - -public record CreateJobResponse -{ - public Guid Id { get; init; } - public string JobType { get; init; } = string.Empty; - public string Status { get; init; } = string.Empty; -} - -public record JobDetailDto -{ - public Guid Id { get; init; } - public Guid CustomerId { get; init; } - public string JobType { get; init; } = string.Empty; - public string Status { get; init; } = string.Empty; - public string? TriggeredBy { get; init; } - public string? Parameters { get; init; } - public DateTime CreatedAt { get; init; } - public DateTime? StartedAt { get; init; } - public DateTime? CompletedAt { get; init; } - public string? ErrorMessage { get; init; } - public List Steps { get; init; } = []; -} - -public record JobStepDto -{ - public Guid Id { get; init; } - public string StepName { get; init; } = string.Empty; - public string Status { get; init; } = string.Empty; - public string? LogOutput { get; init; } - public DateTime? StartedAt { get; init; } - public DateTime? CompletedAt { get; init; } -} - -public record LoginRequest(string Email, string Password); -public record RefreshRequest(string RefreshToken); - -public record AuthResponse -{ - public string Token { get; init; } = string.Empty; - public string RefreshToken { get; init; } = string.Empty; -} - -public record RefreshResponse -{ - public string Token { get; init; } = string.Empty; -} - -// ── Refit interface ───────────────────────────────────────────────────────── - -[Headers("Accept: application/json")] -public interface IServerApiClient -{ - [Get("/api/fleet")] - Task> GetFleetAsync(); - - [Get("/api/fleet/{id}")] - Task GetCustomerDetailAsync(Guid id); - - [Post("/api/jobs")] - Task CreateJobAsync([Body] CreateJobRequest body); - - [Get("/api/jobs/{id}")] - Task GetJobAsync(Guid id); - - [Post("/api/auth/login")] - Task LoginAsync([Body] LoginRequest body); - - [Post("/api/auth/refresh")] - Task RefreshAsync([Body] RefreshRequest body); - - [Get("/api/reports/billing")] - Task GetBillingCsvAsync([Query] DateOnly from, [Query] DateOnly to); - - [Get("/api/reports/fleet-health")] - Task GetFleetHealthPdfAsync(); - - [Post("/api/fleet/bulk/{action}")] - Task BulkActionAsync(string action); -} - -// ── DelegatingHandler for Bearer token injection ──────────────────────────── - -public class AuthHeaderHandler : DelegatingHandler -{ - private readonly TokenStoreService _tokenStore; - - public AuthHeaderHandler(TokenStoreService tokenStore) - { - _tokenStore = tokenStore; - } - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var jwt = _tokenStore.GetJwt(); - if (!string.IsNullOrEmpty(jwt)) - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt); - - return await base.SendAsync(request, cancellationToken); - } -} diff --git a/OTSSignsOrchestrator.Desktop/Services/ServerSignalRService.cs b/OTSSignsOrchestrator.Desktop/Services/ServerSignalRService.cs deleted file mode 100644 index 1a7079b..0000000 --- a/OTSSignsOrchestrator.Desktop/Services/ServerSignalRService.cs +++ /dev/null @@ -1,112 +0,0 @@ -using Avalonia.Threading; -using CommunityToolkit.Mvvm.Messaging; -using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace OTSSignsOrchestrator.Desktop.Services; - -/// -/// Singleton service managing the persistent SignalR connection to the server's FleetHub. -/// All handlers dispatch to the UI thread and republish via . -/// -public sealed class ServerSignalRService : IAsyncDisposable -{ - private readonly HubConnection _connection; - private readonly ILogger _logger; - - public ServerSignalRService( - TokenStoreService tokenStore, - IConfiguration config, - ILogger logger) - { - _logger = logger; - var baseUrl = config["ServerBaseUrl"] ?? "https://localhost:5001"; - - _connection = new HubConnectionBuilder() - .WithUrl($"{baseUrl}/hubs/fleet", options => - { - options.AccessTokenProvider = () => Task.FromResult(tokenStore.GetJwt()); - }) - .WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(30) }) - .Build(); - - RegisterHandlers(); - - _connection.Reconnecting += ex => - { - _logger.LogWarning(ex, "SignalR reconnecting..."); - return Task.CompletedTask; - }; - - _connection.Reconnected += connectionId => - { - _logger.LogInformation("SignalR reconnected (connId={ConnectionId})", connectionId); - return Task.CompletedTask; - }; - - _connection.Closed += ex => - { - _logger.LogWarning(ex, "SignalR connection closed"); - return Task.CompletedTask; - }; - } - - /// - /// Starts the SignalR connection. Call from App.OnFrameworkInitializationCompleted. - /// Failures are logged but do not throw — automatic reconnect will retry. - /// - public async Task StartAsync() - { - try - { - await _connection.StartAsync(); - _logger.LogInformation("SignalR connected to FleetHub"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "SignalR initial connection failed — will retry via automatic reconnect"); - } - } - - public async Task StopAsync() - { - try { await _connection.StopAsync(); } - catch (Exception ex) { _logger.LogWarning(ex, "Error stopping SignalR connection"); } - } - - public HubConnectionState State => _connection.State; - - private void RegisterHandlers() - { - _connection.On("SendJobCreated", (jobId, abbrev, jobType) => - Dispatcher.UIThread.InvokeAsync(() => - WeakReferenceMessenger.Default.Send( - new JobCreatedMessage(new(jobId, abbrev, jobType))))); - - _connection.On("SendJobProgressUpdate", (jobId, stepName, pct, logLine) => - Dispatcher.UIThread.InvokeAsync(() => - WeakReferenceMessenger.Default.Send( - new JobProgressUpdateMessage(new(jobId, stepName, pct, logLine))))); - - _connection.On("SendJobCompleted", (jobId, success, summary) => - Dispatcher.UIThread.InvokeAsync(() => - WeakReferenceMessenger.Default.Send( - new JobCompletedMessage(new(jobId, success, summary))))); - - _connection.On("SendInstanceStatusChanged", (customerId, status) => - Dispatcher.UIThread.InvokeAsync(() => - WeakReferenceMessenger.Default.Send( - new InstanceStatusChangedMessage(new(customerId, status))))); - - _connection.On("SendAlertRaised", (severity, message) => - Dispatcher.UIThread.InvokeAsync(() => - WeakReferenceMessenger.Default.Send( - new AlertRaisedMessage(new(severity, message))))); - } - - public async ValueTask DisposeAsync() - { - await _connection.DisposeAsync(); - } -} diff --git a/OTSSignsOrchestrator.Desktop/Services/SignalRMessages.cs b/OTSSignsOrchestrator.Desktop/Services/SignalRMessages.cs deleted file mode 100644 index 2a4745f..0000000 --- a/OTSSignsOrchestrator.Desktop/Services/SignalRMessages.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CommunityToolkit.Mvvm.Messaging.Messages; - -namespace OTSSignsOrchestrator.Desktop.Services; - -/// SignalR push messages republished via WeakReferenceMessenger for ViewModel consumption. - -public sealed class JobCreatedMessage : ValueChangedMessage -{ - public JobCreatedMessage(Payload value) : base(value) { } - public record Payload(string JobId, string Abbrev, string JobType); -} - -public sealed class JobProgressUpdateMessage : ValueChangedMessage -{ - public JobProgressUpdateMessage(Payload value) : base(value) { } - public record Payload(string JobId, string StepName, int Pct, string LogLine); -} - -public sealed class JobCompletedMessage : ValueChangedMessage -{ - public JobCompletedMessage(Payload value) : base(value) { } - public record Payload(string JobId, bool Success, string Summary); -} - -public sealed class InstanceStatusChangedMessage : ValueChangedMessage -{ - public InstanceStatusChangedMessage(Payload value) : base(value) { } - public record Payload(string CustomerId, string Status); -} - -public sealed class AlertRaisedMessage : ValueChangedMessage -{ - public AlertRaisedMessage(Payload value) : base(value) { } - public record Payload(string Severity, string Message); -} diff --git a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs deleted file mode 100644 index 77f356d..0000000 --- a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs +++ /dev/null @@ -1,780 +0,0 @@ -using System.Diagnostics; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MySqlConnector; -using OTSSignsOrchestrator.Core.Configuration; -using OTSSignsOrchestrator.Core.Models.DTOs; -using OTSSignsOrchestrator.Core.Models.Entities; -using OTSSignsOrchestrator.Core.Services; -using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry; - -namespace OTSSignsOrchestrator.Desktop.Services; - -/// -/// Docker CLI service that executes docker commands on a remote host over SSH. -/// Requires an SshHost to be set before use via SetHost(). -/// -public class SshDockerCliService : IDockerCliService -{ - private readonly SshConnectionService _ssh; - private readonly DockerOptions _options; - private readonly ILogger _logger; - private SshHost? _currentHost; - - public SshDockerCliService( - SshConnectionService ssh, - IOptions options, - ILogger logger) - { - _ssh = ssh; - _options = options.Value; - _logger = logger; - } - - /// - /// Set the SSH host to use for Docker commands. - /// - public void SetHost(SshHost host) - { - _currentHost = host; - } - - public SshHost? CurrentHost => _currentHost; - - private void EnsureHost() - { - if (_currentHost == null) - throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands."); - } - - /// - /// Escape password for safe use in shell scripts with proper quoting. - /// Uses printf-safe escaping to avoid newline injection and special character issues. - /// - private string EscapePasswordForShell(string password) - { - if (string.IsNullOrEmpty(password)) - { - _logger.LogWarning("Password is null or empty"); - return string.Empty; - } - - _logger.LogDebug("Original password length: {Length} characters", password.Length); - - // Use printf-safe format: escape single quotes and other problematic characters - // Replace ' with '\'' (close quote, escaped quote, open quote) - var escaped = password.Replace("'", "'\\''"); - - _logger.LogDebug("Escaped password length: {Length} characters (added {Extra} chars for escaping)", - escaped.Length, escaped.Length - password.Length); - _logger.LogDebug("Password first char: '{FirstChar}', last char: '{LastChar}'", - password.Length > 0 ? password[0].ToString() : "N/A", - password.Length > 0 ? password[^1].ToString() : "N/A"); - - return escaped; - } - - /// - /// Test if the current host's password works with sudo by running a no-op sudo command. - /// - private async Task<(bool Success, string? Error)> TestSudoPasswordAsync() - { - EnsureHost(); - - if (string.IsNullOrEmpty(_currentHost!.Password)) - { - return (false, "No password configured for SSH host"); - } - - var escapedPassword = EscapePasswordForShell(_currentHost!.Password); - var testCmd = $"printf '%s\\n' '{escapedPassword}' | sudo -S -v 2>&1"; - - _logger.LogInformation("Testing sudo password for host {Host} user {User}...", - _currentHost!.Label, _currentHost!.Username); - - var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, testCmd, TimeSpan.FromSeconds(10)); - - if (exitCode == 0) - { - _logger.LogInformation("Sudo password test PASSED for {Host}", _currentHost!.Label); - return (true, null); - } - - var error = (stderr ?? stdout ?? "unknown error").Trim(); - _logger.LogWarning("Sudo password test FAILED for {Host}: {Error}", _currentHost!.Label, error); - return (false, error); - } - - public async Task DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false) - { - EnsureHost(); - var sw = Stopwatch.StartNew(); - - var args = "docker stack deploy --compose-file -"; - if (resolveImage) - args += " --resolve-image changed"; - args += $" {stackName}"; - - _logger.LogInformation("Deploying stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label); - - var (exitCode, stdout, stderr) = await _ssh.RunCommandWithStdinAsync(_currentHost, args, composeYaml); - sw.Stop(); - - var result = new DeploymentResultDto - { - StackName = stackName, - Success = exitCode == 0, - ExitCode = exitCode, - Output = stdout, - ErrorMessage = stderr, - Message = exitCode == 0 ? "Success" : "Failed", - DurationMs = sw.ElapsedMilliseconds - }; - - if (result.Success) - _logger.LogInformation("Stack deployed via SSH: {StackName} | duration={DurationMs}ms", stackName, result.DurationMs); - else - _logger.LogError("Stack deploy failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage); - - return result; - } - - public async Task RemoveStackAsync(string stackName) - { - EnsureHost(); - var sw = Stopwatch.StartNew(); - - _logger.LogInformation("Removing stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label); - - var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost, $"docker stack rm {stackName}"); - sw.Stop(); - - var result = new DeploymentResultDto - { - StackName = stackName, - Success = exitCode == 0, - ExitCode = exitCode, - Output = stdout, - ErrorMessage = stderr, - Message = exitCode == 0 ? "Success" : "Failed", - DurationMs = sw.ElapsedMilliseconds - }; - - if (result.Success) - _logger.LogInformation("Stack removed via SSH: {StackName}", stackName); - else - _logger.LogError("Stack remove failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage); - - return result; - } - - public async Task> ListStacksAsync() - { - EnsureHost(); - - var (exitCode, stdout, _) = await _ssh.RunCommandAsync( - _currentHost!, "docker stack ls --format '{{.Name}}\\t{{.Services}}'"); - - if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) - return new List(); - - return stdout - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(line => - { - var parts = line.Split('\t', 2); - return new StackInfo - { - Name = parts[0].Trim(), - ServiceCount = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var c) ? c : 0 - }; - }) - .ToList(); - } - - public async Task> InspectStackServicesAsync(string stackName) - { - EnsureHost(); - - var (exitCode, stdout, _) = await _ssh.RunCommandAsync( - _currentHost!, $"docker stack services {stackName} --format '{{{{.Name}}}}\\t{{{{.Image}}}}\\t{{{{.Replicas}}}}'"); - - if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) - return new List(); - - return stdout - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(line => - { - var parts = line.Split('\t', 3); - return new ServiceInfo - { - Name = parts.Length > 0 ? parts[0].Trim() : "", - Image = parts.Length > 1 ? parts[1].Trim() : "", - Replicas = parts.Length > 2 ? parts[2].Trim() : "" - }; - }) - .ToList(); - } - - public async Task EnsureDirectoryAsync(string path) - { - EnsureHost(); - var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"mkdir -p {path}"); - if (exitCode != 0) - _logger.LogWarning("Failed to create directory {Path} on {Host}: {Error}", path, _currentHost!.Label, stderr); - else - _logger.LogInformation("Ensured directory exists on {Host}: {Path}", _currentHost!.Label, path); - return exitCode == 0; - } - - public async Task EnsureNfsFoldersAsync( - string nfsServer, - string nfsExport, - IEnumerable folderNames, - string? nfsExportFolder = null) - { - EnsureHost(); - var exportPath = (nfsExport ?? string.Empty).Trim('/'); - var subFolder = (nfsExportFolder ?? string.Empty).Trim('/'); - - // Build the sub-path beneath the mount point where volume folders will be created - var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}"; - - // Build mkdir targets relative to the temporary mount point - var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList(); - var mkdirTargets = string.Join(" ", folderList); - - // Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup - // Use addr= to pin the server IP — avoids "Server address does not match proto= option" - // errors when the hostname resolves to IPv6 but proto=tcp implies IPv4. - // Properly escape password for shell use (handle special characters like single quotes) - var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty); - - if (string.IsNullOrEmpty(escapedPassword)) - { - _logger.LogWarning( - "No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.", - _currentHost!.Label); - } - - var script = $""" - set -e - MNT=$(mktemp -d) - printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT" - printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets} - printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT" - rmdir "$MNT" - """; - - _logger.LogInformation( - "Mounting NFS export {Server}:/{Export} on Docker host {Host} to create {Count} folders", - nfsServer, exportPath, _currentHost!.Label, folderList.Count); - - var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30)); - - if (exitCode == 0) - { - _logger.LogInformation( - "NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)", - _currentHost.Label, nfsServer, exportPath, subPath, folderList.Count); - } - else - { - _logger.LogWarning( - "Failed to create NFS export folders on {Host}: {Error}", - _currentHost.Label, (stderr ?? stdout ?? "unknown error").Trim()); - return false; - } - - return true; - } - - public async Task<(bool Success, string? Error)> EnsureNfsFoldersWithErrorAsync( - string nfsServer, - string nfsExport, - IEnumerable folderNames, - string? nfsExportFolder = null) - { - EnsureHost(); - var exportPath = (nfsExport ?? string.Empty).Trim('/'); - var subFolder = (nfsExportFolder ?? string.Empty).Trim('/'); - var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}"; - var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList(); - var mkdirTargets = string.Join(" ", folderList); - - // Properly escape password for shell use (handle special characters like single quotes) - var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty); - - if (string.IsNullOrEmpty(escapedPassword)) - { - _logger.LogWarning( - "No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.", - _currentHost!.Label); - } - - var script = $""" - set -e - MNT=$(mktemp -d) - printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT" - printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets} - printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT" - rmdir "$MNT" - """; - - _logger.LogInformation( - "Mounting NFS export {Server}:/{Export} on Docker host {Host} to create {Count} folders", - nfsServer, exportPath, _currentHost!.Label, folderList.Count); - - var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30)); - - if (exitCode == 0) - { - _logger.LogInformation( - "NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)", - _currentHost.Label, nfsServer, exportPath, subPath, folderList.Count); - return (true, null); - } - - var error = (stderr ?? stdout ?? "unknown error").Trim(); - _logger.LogWarning( - "Failed to create NFS export folders on {Host}: {Error}", - _currentHost.Label, error); - return (false, error); - } - - public async Task<(bool Success, string? Error)> WriteFileToNfsAsync( - string nfsServer, - string nfsExport, - string relativePath, - string content, - string? nfsExportFolder = null) - { - EnsureHost(); - var exportPath = (nfsExport ?? string.Empty).Trim('/'); - var subFolder = (nfsExportFolder ?? string.Empty).Trim('/'); - var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}"; - - // Ensure parent directory exists - var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}"; - var parentDir = $"$(dirname \"{targetPath}\")"; - - // Properly escape password for shell use (handle special characters like single quotes) - var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty); - - _logger.LogInformation("NFS WriteFile: Host={Host}, User={User}, HasPassword={HasPw}, PwLen={PwLen}", - _currentHost!.Label, _currentHost!.Username, - !string.IsNullOrEmpty(_currentHost!.Password), _currentHost!.Password?.Length ?? 0); - - if (string.IsNullOrEmpty(escapedPassword)) - { - _logger.LogWarning( - "No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.", - _currentHost!.Label); - return (false, "No password configured for SSH host"); - } - - // Base64-encode the file content to avoid heredoc/stdin conflicts with sudo -S. - // The heredoc approach fails because the shell's heredoc redirects stdin for the - // entire pipeline, so sudo -S reads the PHP content instead of the password. - var base64Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content)); - - // Strategy: base64-decode content to a temp file (no sudo needed), then use - // printf | sudo -S for each privileged command — matching the proven pattern - // in EnsureNfsFoldersAsync. We avoid sudo -v timestamp caching because SSH - // exec channels have no TTY and timestamps may not persist between commands. - var script = $""" - set -e - TMPFILE=$(mktemp) - echo '{base64Content}' | base64 -d > "$TMPFILE" - MNT=$(mktemp -d) - printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT" - printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {parentDir} - printf '%s\n' '{escapedPassword}' | sudo -S cp "$TMPFILE" "{targetPath}" - rm -f "$TMPFILE" - printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT" - rmdir "$MNT" - """; - - _logger.LogInformation( - "Writing file to NFS {Server}:/{Export}{Sub}/{Path} on Docker host {Host}", - nfsServer, exportPath, subPath, relativePath, _currentHost!.Label); - - var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30)); - - if (exitCode == 0) - { - _logger.LogInformation( - "File written to NFS on {Host}: {Server}:/{Export}{Sub}/{Path}", - _currentHost.Label, nfsServer, exportPath, subPath, relativePath); - return (true, null); - } - - var error = (stderr ?? stdout ?? "unknown error").Trim(); - _logger.LogWarning( - "Failed to write file to NFS on {Host}: {Error}", - _currentHost.Label, error); - return (false, error); - } - - public async Task ForceUpdateServiceAsync(string serviceName) - { - EnsureHost(); - _logger.LogInformation("Force-updating service {ServiceName} on {Host}", serviceName, _currentHost!.Label); - var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"docker service update --force {serviceName}"); - if (exitCode != 0) - _logger.LogWarning("Force-update failed for {ServiceName}: {Error}", serviceName, stderr); - return exitCode == 0; - } - - public async Task<(MySqlConnection Connection, IDisposable Tunnel)> OpenMySqlConnectionAsync( - string mysqlHost, int port, - string adminUser, string adminPassword) - { - EnsureHost(); - _logger.LogInformation( - "Opening tunnelled MySQL connection to {MysqlHost}:{Port} via SSH", - mysqlHost, port); - - var tunnel = _ssh.OpenForwardedPort(_currentHost!, mysqlHost, (uint)port); - var localPort = (int)tunnel.BoundPort; - - var csb = new MySqlConnectionStringBuilder - { - Server = "127.0.0.1", - Port = (uint)localPort, - UserID = adminUser, - Password = adminPassword, - ConnectionTimeout = 15, - SslMode = MySqlSslMode.Disabled, - }; - - var connection = new MySqlConnection(csb.ConnectionString); - try - { - await connection.OpenAsync(); - return (connection, tunnel); - } - catch - { - await connection.DisposeAsync(); - tunnel.Dispose(); - throw; - } - } - - public async Task<(bool Success, string Error)> AlterMySqlUserPasswordAsync( - string mysqlHost, int port, - string adminUser, string adminPassword, - string targetUser, string newPassword) - { - _logger.LogInformation( - "Altering MySQL password for user {User} on {MysqlHost}:{Port} via SSH tunnel", - targetUser, mysqlHost, port); - - try - { - var (connection, tunnel) = await OpenMySqlConnectionAsync(mysqlHost, port, adminUser, adminPassword); - await using (connection) - using (tunnel) - { - var escapedUser = targetUser.Replace("'", "''"); - await using var cmd = connection.CreateCommand(); - cmd.CommandText = $"ALTER USER '{escapedUser}'@'%' IDENTIFIED BY @pwd"; - cmd.Parameters.AddWithValue("@pwd", newPassword); - await cmd.ExecuteNonQueryAsync(); - } - - _logger.LogInformation("MySQL password updated for user {User} via SSH tunnel", targetUser); - return (true, string.Empty); - } - catch (MySqlException ex) - { - _logger.LogError(ex, "MySQL ALTER USER failed via SSH tunnel for user {User}", targetUser); - return (false, ex.Message); - } - } - - public async Task ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null) - { - EnsureHost(); - var target = targetAlias ?? oldSecretName; - var cmd = $"docker service update --secret-rm {oldSecretName} --secret-add \"source={newSecretName},target={target}\" {serviceName}"; - _logger.LogInformation( - "Swapping secret on {ServiceName}: {OldSecret} → {NewSecret} (target={Target})", - serviceName, oldSecretName, newSecretName, target); - var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, cmd); - if (exitCode != 0) - _logger.LogError("Secret swap failed for {ServiceName}: {Error}", serviceName, stderr); - return exitCode == 0; - } - - public async Task> ListNodesAsync() - { - EnsureHost(); - - _logger.LogInformation("Listing swarm nodes via SSH on {Host}", _currentHost!.Label); - - // Use docker node inspect on all nodes to get IP addresses (Status.Addr) - // that are not available from 'docker node ls'. - // First, get all node IDs. - var (lsExit, lsOut, lsErr) = await _ssh.RunCommandAsync( - _currentHost!, "docker node ls --format '{{.ID}}'"); - - if (lsExit != 0) - { - var msg = (lsErr ?? lsOut ?? "unknown error").Trim(); - _logger.LogWarning("docker node ls failed on {Host} (exit {Code}): {Error}", - _currentHost.Label, lsExit, msg); - throw new InvalidOperationException( - $"Failed to list swarm nodes on {_currentHost.Label}: {msg}"); - } - - if (string.IsNullOrWhiteSpace(lsOut)) - return new List(); - - var nodeIds = lsOut.Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(id => id.Trim()) - .Where(id => !string.IsNullOrEmpty(id)) - .ToList(); - - if (nodeIds.Count == 0) - return new List(); - - // Inspect all nodes in a single call to get full details including IP address - var ids = string.Join(" ", nodeIds); - var format = "'{{.ID}}\t{{.Description.Hostname}}\t{{.Status.State}}\t{{.Spec.Availability}}\t{{.ManagerStatus.Addr}}\t{{.Status.Addr}}\t{{.Description.Engine.EngineVersion}}\t{{.Spec.Role}}'"; - var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync( - _currentHost!, $"docker node inspect --format {format} {ids}"); - - if (exitCode != 0) - { - var msg = (stderr ?? stdout ?? "unknown error").Trim(); - _logger.LogWarning("docker node inspect failed on {Host} (exit {Code}): {Error}", - _currentHost.Label, exitCode, msg); - throw new InvalidOperationException( - $"Failed to inspect swarm nodes on {_currentHost.Label}: {msg}"); - } - - if (string.IsNullOrWhiteSpace(stdout)) - return new List(); - - return stdout - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(line => - { - var parts = line.Split('\t', 8); - // ManagerStatus.Addr includes port (e.g. "10.0.0.1:2377"); Status.Addr is just the IP. - // Prefer Status.Addr; fall back to ManagerStatus.Addr (strip port) if Status.Addr is empty/template-error. - var statusAddr = parts.Length > 5 ? parts[5].Trim() : ""; - var managerAddr = parts.Length > 4 ? parts[4].Trim() : ""; - var ip = statusAddr; - if (string.IsNullOrEmpty(ip) || ip.StartsWith("<") || ip.StartsWith("{")) - { - // managerAddr may be "10.0.0.1:2377" - ip = managerAddr.Contains(':') ? managerAddr[..managerAddr.LastIndexOf(':')] : managerAddr; - } - // Clean up template rendering artefacts like "" - if (ip.StartsWith("<") || ip.StartsWith("{")) - ip = ""; - - var role = parts.Length > 7 ? parts[7].Trim() : ""; - var managerStatus = ""; - if (string.Equals(role, "manager", StringComparison.OrdinalIgnoreCase)) - { - // Determine if this is the leader by checking if ManagerStatus.Addr is non-empty - managerStatus = !string.IsNullOrEmpty(managerAddr) && !managerAddr.StartsWith("<") ? "Reachable" : ""; - } - - return new NodeInfo - { - Id = parts.Length > 0 ? parts[0].Trim() : "", - Hostname = parts.Length > 1 ? parts[1].Trim() : "", - Status = parts.Length > 2 ? parts[2].Trim() : "", - Availability = parts.Length > 3 ? parts[3].Trim() : "", - ManagerStatus = managerStatus, - IpAddress = ip, - EngineVersion = parts.Length > 6 ? parts[6].Trim() : "" - }; - }) - .ToList(); - } - - public async Task> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200) - { - EnsureHost(); - - // Determine which services to fetch logs for - List serviceNames; - if (!string.IsNullOrEmpty(serviceName)) - { - serviceNames = new List { serviceName }; - } - else - { - var services = await InspectStackServicesAsync(stackName); - serviceNames = services.Select(s => s.Name).ToList(); - } - - var allEntries = new List(); - foreach (var svcName in serviceNames) - { - try - { - var cmd = $"docker service logs --timestamps --no-trunc --tail {tailLines} {svcName} 2>&1"; - var (exitCode, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd, TimeSpan.FromSeconds(15)); - - if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) - { - _logger.LogDebug("No logs returned for service {Service} (exit={ExitCode})", svcName, exitCode); - continue; - } - - // Parse each line. Docker service logs format with --timestamps: - // ..@ | - // or sometimes just: - // .. - foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - var entry = ParseLogLine(line, svcName, stackName); - if (entry != null) - allEntries.Add(entry); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to fetch logs for service {Service}", svcName); - } - } - - return allEntries.OrderBy(e => e.Timestamp).ToList(); - } - - /// - /// Parses a single line from docker service logs --timestamps output. - /// - private static ServiceLogEntry? ParseLogLine(string line, string serviceName, string stackName) - { - if (string.IsNullOrWhiteSpace(line)) - return null; - - // Format: "2026-02-25T14:30:45.123456789Z service.replica.taskid@node | message" - // The timestamp is always the first space-delimited token when --timestamps is used. - var firstSpace = line.IndexOf(' '); - if (firstSpace <= 0) - return new ServiceLogEntry - { - Timestamp = DateTimeOffset.UtcNow, - Source = serviceName, - ServiceName = StripStackPrefix(serviceName, stackName), - Message = line - }; - - var timestampStr = line[..firstSpace]; - var rest = line[(firstSpace + 1)..].TrimStart(); - - // Try to parse the timestamp - if (!DateTimeOffset.TryParse(timestampStr, out var timestamp)) - { - // If timestamp parsing fails, treat the whole line as the message - return new ServiceLogEntry - { - Timestamp = DateTimeOffset.UtcNow, - Source = serviceName, - ServiceName = StripStackPrefix(serviceName, stackName), - Message = line - }; - } - - // Split source and message on the pipe separator - var source = serviceName; - var message = rest; - var pipeIndex = rest.IndexOf('|'); - if (pipeIndex >= 0) - { - source = rest[..pipeIndex].Trim(); - message = rest[(pipeIndex + 1)..].TrimStart(); - } - - return new ServiceLogEntry - { - Timestamp = timestamp, - Source = source, - ServiceName = StripStackPrefix(serviceName, stackName), - Message = message - }; - } - - /// - /// Strips the stack name prefix from a fully-qualified service name. - /// e.g. "acm-cms-stack_acm-web" → "acm-web" - /// - private static string StripStackPrefix(string serviceName, string stackName) - { - var prefix = stackName + "_"; - return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName; - } - - public async Task RemoveStackVolumesAsync(string stackName) - { - EnsureHost(); - - // ── 1. Remove the stack first so containers release the volumes ───── - _logger.LogInformation("Removing stack {StackName} before volume cleanup", stackName); - var (rmExit, _, rmErr) = await _ssh.RunCommandAsync(_currentHost!, - $"docker stack rm {stackName} 2>&1 || true"); - if (rmExit != 0) - _logger.LogWarning("Stack rm returned non-zero for {StackName}: {Err}", stackName, rmErr); - - // Give Swarm a moment to tear down containers on all nodes - await Task.Delay(5000); - - // ── 2. Clean volumes on the local (manager) node ──────────────────── - var localCmd = $"docker volume ls --filter \"name={stackName}_\" -q | xargs -r docker volume rm 2>&1 || true"; - var (_, localOut, _) = await _ssh.RunCommandAsync(_currentHost!, localCmd); - if (!string.IsNullOrEmpty(localOut?.Trim())) - _logger.LogInformation("Volume cleanup (manager): {Output}", localOut!.Trim()); - - // ── 3. Clean volumes on ALL swarm nodes via a temporary global service ── - // This deploys a short-lived container on every node that mounts the Docker - // socket and removes matching volumes. This handles worker nodes that the - // orchestrator has no direct SSH access to. - var cleanupSvcName = $"vol-cleanup-{stackName}".Replace("_", "-"); - - // Remove leftover cleanup service from a previous run (if any) - await _ssh.RunCommandAsync(_currentHost!, - $"docker service rm {cleanupSvcName} 2>/dev/null || true"); - - var createCmd = string.Join(" ", - "docker service create", - "--detach", - "--mode global", - "--restart-condition none", - $"--name {cleanupSvcName}", - "--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock", - "docker:cli", - "sh", "-c", - $"'docker volume ls -q --filter name={stackName}_ | xargs -r docker volume rm 2>&1; echo done'"); - - _logger.LogInformation("Deploying global volume-cleanup service on all swarm nodes for {StackName}", stackName); - var (svcExit, svcOut, svcErr) = await _ssh.RunCommandAsync(_currentHost!, createCmd); - - if (svcExit != 0) - { - _logger.LogWarning("Global volume cleanup service creation failed: {Err}", svcErr); - } - else - { - // Wait for the cleanup tasks to finish on all nodes - _logger.LogInformation("Waiting for volume cleanup tasks to complete on all nodes..."); - await Task.Delay(10000); - } - - // Remove the cleanup service - await _ssh.RunCommandAsync(_currentHost!, - $"docker service rm {cleanupSvcName} 2>/dev/null || true"); - - _logger.LogInformation("Volume cleanup complete for stack {StackName}", stackName); - return true; - } -} diff --git a/OTSSignsOrchestrator.Desktop/Services/TokenStoreService.cs b/OTSSignsOrchestrator.Desktop/Services/TokenStoreService.cs deleted file mode 100644 index fef26e5..0000000 --- a/OTSSignsOrchestrator.Desktop/Services/TokenStoreService.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Text; -using Microsoft.Extensions.Logging; - -namespace OTSSignsOrchestrator.Desktop.Services; - -/// -/// Stores and retrieves operator JWT and refresh tokens using the OS credential store. -/// Windows: advapi32 Credential Manager; macOS: Security.framework Keychain; -/// Linux: AES-encrypted file fallback in AppData. -/// -public sealed class TokenStoreService -{ - private const string ServiceName = "OTSSignsOrchestrator"; - private const string JwtAccount = "operator-jwt"; - private const string RefreshAccount = "operator-refresh"; - - private readonly ILogger _logger; - - public TokenStoreService(ILogger logger) - { - _logger = logger; - } - - public void StoreTokens(string jwt, string refreshToken) - { - WriteCredential(JwtAccount, jwt); - WriteCredential(RefreshAccount, refreshToken); - _logger.LogDebug("Tokens stored in OS credential store"); - } - - public string? GetJwt() => ReadCredential(JwtAccount); - - public string? GetRefreshToken() => ReadCredential(RefreshAccount); - - public void ClearTokens() - { - DeleteCredential(JwtAccount); - DeleteCredential(RefreshAccount); - _logger.LogDebug("Tokens cleared from OS credential store"); - } - - // ── Platform dispatch ──────────────────────────────────────────────────── - - private void WriteCredential(string account, string secret) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - WindowsCredentialManager.Write(ServiceName, account, secret); - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - MacKeychain.Write(ServiceName, account, secret); - else - LinuxEncryptedFile.Write(ServiceName, account, secret); - } - - private string? ReadCredential(string account) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return WindowsCredentialManager.Read(ServiceName, account); - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - return MacKeychain.Read(ServiceName, account); - return LinuxEncryptedFile.Read(ServiceName, account); - } - - private void DeleteCredential(string account) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - WindowsCredentialManager.Delete(ServiceName, account); - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - MacKeychain.Delete(ServiceName, account); - else - LinuxEncryptedFile.Delete(ServiceName, account); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Windows — advapi32.dll Credential Manager - // ═══════════════════════════════════════════════════════════════════════ - private static class WindowsCredentialManager - { - private const int CredTypeGeneric = 1; - private const int CredPersistLocalMachine = 2; - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct CREDENTIAL - { - public uint Flags; - public uint Type; - public string TargetName; - public string Comment; - public long LastWritten; - public uint CredentialBlobSize; - public IntPtr CredentialBlob; - public uint Persist; - public uint AttributeCount; - public IntPtr Attributes; - public string TargetAlias; - public string UserName; - } - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern bool CredWriteW(ref CREDENTIAL credential, uint flags); - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern bool CredReadW(string target, uint type, uint flags, out IntPtr credential); - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern bool CredDeleteW(string target, uint type, uint flags); - - [DllImport("advapi32.dll")] - private static extern void CredFree(IntPtr buffer); - - private static string TargetName(string service, string account) => $"{service}/{account}"; - - public static void Write(string service, string account, string secret) - { - var bytes = Encoding.UTF8.GetBytes(secret); - var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); - try - { - var cred = new CREDENTIAL - { - Type = CredTypeGeneric, - TargetName = TargetName(service, account), - UserName = account, - CredentialBlob = handle.AddrOfPinnedObject(), - CredentialBlobSize = (uint)bytes.Length, - Persist = CredPersistLocalMachine, - }; - CredWriteW(ref cred, 0); - } - finally { handle.Free(); } - } - - public static string? Read(string service, string account) - { - if (!CredReadW(TargetName(service, account), CredTypeGeneric, 0, out var credPtr)) - return null; - try - { - var cred = Marshal.PtrToStructure(credPtr); - if (cred.CredentialBlobSize == 0 || cred.CredentialBlob == IntPtr.Zero) return null; - var bytes = new byte[cred.CredentialBlobSize]; - Marshal.Copy(cred.CredentialBlob, bytes, 0, bytes.Length); - return Encoding.UTF8.GetString(bytes); - } - finally { CredFree(credPtr); } - } - - public static void Delete(string service, string account) - => CredDeleteW(TargetName(service, account), CredTypeGeneric, 0); - } - - // ═══════════════════════════════════════════════════════════════════════ - // macOS — Security.framework Keychain via /usr/bin/security CLI - // ═══════════════════════════════════════════════════════════════════════ - private static class MacKeychain - { - public static void Write(string service, string account, string secret) - { - // Delete first to avoid "duplicate" errors on update - Delete(service, account); - RunSecurity($"add-generic-password -s \"{service}\" -a \"{account}\" -w \"{EscapeShell(secret)}\" -U"); - } - - public static string? Read(string service, string account) - { - var (exitCode, stdout) = RunSecurity($"find-generic-password -s \"{service}\" -a \"{account}\" -w"); - return exitCode == 0 ? stdout.Trim() : null; - } - - public static void Delete(string service, string account) - => RunSecurity($"delete-generic-password -s \"{service}\" -a \"{account}\""); - - private static (int exitCode, string stdout) RunSecurity(string args) - { - using var proc = new System.Diagnostics.Process(); - proc.StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "/usr/bin/security", - Arguments = args, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - proc.Start(); - var stdout = proc.StandardOutput.ReadToEnd(); - proc.WaitForExit(5000); - return (proc.ExitCode, stdout); - } - - private static string EscapeShell(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\""); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Linux — AES-256-GCM encrypted file in ~/.local/share - // ═══════════════════════════════════════════════════════════════════════ - private static class LinuxEncryptedFile - { - // Machine-specific key derived from machine-id + user name - private static byte[] DeriveKey() - { - var machineId = "linux-default"; - try - { - if (File.Exists("/etc/machine-id")) - machineId = File.ReadAllText("/etc/machine-id").Trim(); - } - catch { /* fallback */ } - - var material = $"{machineId}:{Environment.UserName}:{ServiceName}"; - return SHA256.HashData(Encoding.UTF8.GetBytes(material)); - } - - private static string FilePath(string service, string account) - { - var dir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - service, "credentials"); - Directory.CreateDirectory(dir); - return Path.Combine(dir, $"{account}.enc"); - } - - public static void Write(string service, string account, string secret) - { - var key = DeriveKey(); - var plaintext = Encoding.UTF8.GetBytes(secret); - var nonce = new byte[12]; - RandomNumberGenerator.Fill(nonce); - var ciphertext = new byte[plaintext.Length]; - var tag = new byte[16]; - - using var aes = new AesGcm(key, 16); - aes.Encrypt(nonce, plaintext, ciphertext, tag); - - // File format: [12 nonce][16 tag][ciphertext] - var output = new byte[12 + 16 + ciphertext.Length]; - nonce.CopyTo(output, 0); - tag.CopyTo(output, 12); - ciphertext.CopyTo(output, 28); - File.WriteAllBytes(FilePath(service, account), output); - } - - public static string? Read(string service, string account) - { - var path = FilePath(service, account); - if (!File.Exists(path)) return null; - - var data = File.ReadAllBytes(path); - if (data.Length < 28) return null; - - var nonce = data[..12]; - var tag = data[12..28]; - var ciphertext = data[28..]; - var plaintext = new byte[ciphertext.Length]; - - using var aes = new AesGcm(DeriveKey(), 16); - aes.Decrypt(nonce, ciphertext, tag, plaintext); - return Encoding.UTF8.GetString(plaintext); - } - - public static void Delete(string service, string account) - { - var path = FilePath(service, account); - if (File.Exists(path)) File.Delete(path); - } - } -} diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs deleted file mode 100644 index e3b1a90..0000000 --- a/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs +++ /dev/null @@ -1,362 +0,0 @@ -using System.Collections.ObjectModel; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using OTSSignsOrchestrator.Core.Data; -using OTSSignsOrchestrator.Core.Models.DTOs; -using OTSSignsOrchestrator.Core.Models.Entities; -using OTSSignsOrchestrator.Core.Services; -using OTSSignsOrchestrator.Desktop.Services; - -namespace OTSSignsOrchestrator.Desktop.ViewModels; - -/// -/// ViewModel for the Create Instance form. -/// Simplified: Customer Name, Abbreviation, SSH Host, and optional Newt credentials. -/// All other config comes from the Settings page. -/// -public partial class CreateInstanceViewModel : ObservableObject -{ - private readonly IServiceProvider _services; - private readonly MainWindowViewModel _mainVm; - - [ObservableProperty] private string _statusMessage = string.Empty; - [ObservableProperty] private bool _isBusy; - [ObservableProperty] private string _deployOutput = string.Empty; - [ObservableProperty] private double _progressPercent; - [ObservableProperty] private string _progressStep = string.Empty; - - // Core form fields — only these two are required from the user - [ObservableProperty] private string _customerName = string.Empty; - [ObservableProperty] private string _customerAbbrev = string.Empty; - - // Optional Pangolin/Newt credentials (per-instance) - [ObservableProperty] private string _newtId = string.Empty; - [ObservableProperty] private string _newtSecret = string.Empty; - - // NFS volume settings (per-instance, defaults loaded from global settings) - [ObservableProperty] private string _nfsServer = string.Empty; - [ObservableProperty] private string _nfsExport = string.Empty; - [ObservableProperty] private string _nfsExportFolder = string.Empty; - [ObservableProperty] private string _nfsExtraOptions = string.Empty; - - /// When enabled, existing Docker volumes for the stack are removed before deploying. - [ObservableProperty] private bool _purgeStaleVolumes = false; - - // SSH host selection - [ObservableProperty] private ObservableCollection _availableHosts = new(); - [ObservableProperty] private SshHost? _selectedSshHost; - - // YML preview - [ObservableProperty] private string _previewYml = string.Empty; - [ObservableProperty] private bool _isLoadingYml; - - public bool HasPreviewYml => !string.IsNullOrEmpty(PreviewYml); - partial void OnPreviewYmlChanged(string value) => OnPropertyChanged(nameof(HasPreviewYml)); - - // ── Derived preview properties ─────────────────────────────────────────── - - public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—"; - public string PreviewServiceWeb => Valid ? $"{Abbrev}-web" : "—"; - public string PreviewServiceCache => Valid ? $"{Abbrev}-memcached" : "—"; - public string PreviewServiceChart => Valid ? $"{Abbrev}-quickchart" : "—"; - public string PreviewServiceNewt => Valid ? $"{Abbrev}-newt" : "—"; - public string PreviewNetwork => Valid ? $"{Abbrev}-net" : "—"; - public string PreviewVolCustom => Valid ? $"{Abbrev}/cms-custom" : "—"; - public string PreviewVolBackup => Valid ? $"{Abbrev}/cms-backup" : "—"; - public string PreviewVolLibrary => Valid ? $"{Abbrev}/cms-library" : "—"; - public string PreviewVolUserscripts => Valid ? $"{Abbrev}/cms-userscripts": "—"; - public string PreviewVolCaCerts => Valid ? $"{Abbrev}/cms-ca-certs" : "—"; - public string PreviewSecret => Valid ? $"{Abbrev}-cms-db-password": "—"; - public string PreviewSecretUser => Valid ? $"{Abbrev}-cms-db-user" : "—"; - public string PreviewSecretHost => "global_mysql_host"; - public string PreviewSecretPort => "global_mysql_port"; - public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—"; - public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms_user" : "—"; - public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—"; - - private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant(); - private bool Valid => Abbrev.Length == 3 && System.Text.RegularExpressions.Regex.IsMatch(Abbrev, "^[a-z]{3}$"); - - // ───────────────────────────────────────────────────────────────────────── - - public CreateInstanceViewModel(IServiceProvider services, MainWindowViewModel mainVm) - { - _services = services; - _mainVm = mainVm; - _ = LoadHostsAsync(); - _ = LoadNfsDefaultsAsync(); - } - - partial void OnCustomerAbbrevChanged(string value) => RefreshPreview(); - - private void RefreshPreview() - { - OnPropertyChanged(nameof(PreviewStackName)); - OnPropertyChanged(nameof(PreviewServiceWeb)); - OnPropertyChanged(nameof(PreviewServiceCache)); - OnPropertyChanged(nameof(PreviewServiceChart)); - OnPropertyChanged(nameof(PreviewServiceNewt)); - OnPropertyChanged(nameof(PreviewNetwork)); - OnPropertyChanged(nameof(PreviewVolCustom)); - OnPropertyChanged(nameof(PreviewVolBackup)); - OnPropertyChanged(nameof(PreviewVolLibrary)); - OnPropertyChanged(nameof(PreviewVolUserscripts)); - OnPropertyChanged(nameof(PreviewVolCaCerts)); - OnPropertyChanged(nameof(PreviewSecret)); - OnPropertyChanged(nameof(PreviewSecretUser)); - OnPropertyChanged(nameof(PreviewSecretHost)); - OnPropertyChanged(nameof(PreviewSecretPort)); - OnPropertyChanged(nameof(PreviewMySqlDb)); - OnPropertyChanged(nameof(PreviewMySqlUser)); - OnPropertyChanged(nameof(PreviewCmsUrl)); - } - - private async Task LoadHostsAsync() - { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync(); - AvailableHosts = new ObservableCollection(hosts); - } - - private async Task LoadNfsDefaultsAsync() - { - using var scope = _services.CreateScope(); - var settings = scope.ServiceProvider.GetRequiredService(); - NfsServer = await settings.GetAsync(SettingsService.NfsServer) ?? string.Empty; - NfsExport = await settings.GetAsync(SettingsService.NfsExport) ?? string.Empty; - NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder) ?? string.Empty; - NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions) ?? string.Empty; - } - - [RelayCommand] - private async Task LoadYmlPreviewAsync() - { - if (!Valid) - { - PreviewYml = "# Abbreviation must be exactly 3 lowercase letters (a-z) before loading the YML preview."; - return; - } - - IsLoadingYml = true; - try - { - using var scope = _services.CreateScope(); - var settings = scope.ServiceProvider.GetRequiredService(); - var git = scope.ServiceProvider.GetRequiredService(); - var composer = scope.ServiceProvider.GetRequiredService(); - - var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl); - var repoPat = await settings.GetAsync(SettingsService.GitRepoPat); - if (string.IsNullOrWhiteSpace(repoUrl)) - { - PreviewYml = "# Git template repository URL is not configured. Set it in Settings → Git Repo URL."; - return; - } - - var templateConfig = await git.FetchAsync(repoUrl, repoPat); - - var abbrev = Abbrev; - var stackName = $"{abbrev}-cms-stack"; - - var mySqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost"); - var mySqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306"); - var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); - var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev); - - var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev); - var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev); - - var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty); - var smtpUsername = await settings.GetAsync(SettingsService.SmtpUsername, string.Empty); - var smtpPassword = await settings.GetAsync(SettingsService.SmtpPassword, string.Empty); - var smtpUseTls = await settings.GetAsync(SettingsService.SmtpUseTls, "YES"); - var smtpUseStartTls = await settings.GetAsync(SettingsService.SmtpUseStartTls, "YES"); - var smtpRewriteDomain = await settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty); - var smtpHostname = await settings.GetAsync(SettingsService.SmtpHostname, string.Empty); - var smtpFromLineOverride = await settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO"); - - var pangolinEndpoint = await settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"); - - var cmsImage = await settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"); - var newtImage = await settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"); - var memcachedImage = await settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"); - var quickChartImage = await settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart"); - - var phpPostMaxSize = await settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"); - var phpUploadMaxFilesize = await settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"); - var phpMaxExecutionTime = await settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); - - // Use form values; fall back to saved global settings - var nfsServer = string.IsNullOrWhiteSpace(NfsServer) ? await settings.GetAsync(SettingsService.NfsServer) : NfsServer; - var nfsExport = string.IsNullOrWhiteSpace(NfsExport) ? await settings.GetAsync(SettingsService.NfsExport) : NfsExport; - var nfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? await settings.GetAsync(SettingsService.NfsExportFolder) : NfsExportFolder; - var nfsOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? await settings.GetAsync(SettingsService.NfsOptions, string.Empty) : NfsExtraOptions; - - var ctx = new RenderContext - { - CustomerName = CustomerName.Trim(), - CustomerAbbrev = abbrev, - StackName = stackName, - CmsServerName = cmsServerName, - HostHttpPort = 80, - CmsImage = cmsImage, - MemcachedImage = memcachedImage, - QuickChartImage = quickChartImage, - NewtImage = newtImage, - ThemeHostPath = themePath, - MySqlHost = mySqlHost, - MySqlPort = mySqlPort, - MySqlDatabase = mySqlDbName, - MySqlUser = mySqlUser, - SmtpServer = smtpServer, - SmtpUsername = smtpUsername, - SmtpPassword = smtpPassword, - SmtpUseTls = smtpUseTls, - SmtpUseStartTls = smtpUseStartTls, - SmtpRewriteDomain = smtpRewriteDomain, - SmtpHostname = smtpHostname, - SmtpFromLineOverride = smtpFromLineOverride, - PhpPostMaxSize = phpPostMaxSize, - PhpUploadMaxFilesize = phpUploadMaxFilesize, - PhpMaxExecutionTime = phpMaxExecutionTime, - PangolinEndpoint = pangolinEndpoint, - NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(), - NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(), - NfsServer = nfsServer, - NfsExport = nfsExport, - NfsExportFolder = nfsExportFolder, - NfsExtraOptions = nfsOptions, - }; - - PreviewYml = composer.Render(templateConfig.Yaml, ctx); - } - catch (Exception ex) - { - PreviewYml = $"# Error rendering YML preview:\n# {ex.Message}"; - } - finally - { - IsLoadingYml = false; - } - } - - [RelayCommand] - private async Task CopyYmlAsync() - { - if (string.IsNullOrEmpty(PreviewYml)) return; - var mainWindow = (Application.Current?.ApplicationLifetime - as IClassicDesktopStyleApplicationLifetime)?.MainWindow; - if (mainWindow is null) return; - var clipboard = TopLevel.GetTopLevel(mainWindow)?.Clipboard; - if (clipboard is not null) - await clipboard.SetTextAsync(PreviewYml); - } - - [RelayCommand] - private async Task DeployAsync() - { - // ── Validation ─────────────────────────────────────────────────── - if (SelectedSshHost == null) - { - StatusMessage = "Select an SSH host first."; - return; - } - if (string.IsNullOrWhiteSpace(CustomerName)) - { - StatusMessage = "Customer Name is required."; - return; - } - if (!Valid) - { - StatusMessage = "Abbreviation must be exactly 3 lowercase letters (a-z)."; - return; - } - - IsBusy = true; - StatusMessage = "Starting deployment..."; - DeployOutput = string.Empty; - ProgressPercent = 0; - - try - { - // Wire SSH host into docker services (singletons must know the target host before - // InstanceService uses them internally for secrets and CLI operations) - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(SelectedSshHost); - var dockerSecrets = _services.GetRequiredService(); - dockerSecrets.SetHost(SelectedSshHost); - - using var scope = _services.CreateScope(); - var instanceSvc = scope.ServiceProvider.GetRequiredService(); - - // InstanceService.CreateInstanceAsync handles the full provisioning flow: - // 1. Clone template repo - // 2. Generate MySQL password → create Docker Swarm secret - // 3. Create MySQL database + SQL user (same password as the secret) - // 4. Render compose YAML → deploy stack - SetProgress(30, "Provisioning instance (MySQL user, secrets, stack)..."); - - var dto = new CreateInstanceDto - { - CustomerName = CustomerName.Trim(), - CustomerAbbrev = Abbrev, - SshHostId = SelectedSshHost.Id, - NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(), - NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(), - NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(), - NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(), - NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(), - NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(), - PurgeStaleVolumes = PurgeStaleVolumes, - }; - - var result = await instanceSvc.CreateInstanceAsync(dto); - - AppendOutput(result.Output ?? string.Empty); - - if (result.Success) - { - SetProgress(100, "Stack deployed successfully."); - StatusMessage = $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms. " + - "Open the details pane on the Instances page to complete setup."; - _mainVm.NavigateToInstancesWithSelection(Abbrev); - } - else - { - SetProgress(0, "Deployment failed."); - StatusMessage = $"Deploy failed: {result.ErrorMessage}"; - } - } - catch (Exception ex) - { - StatusMessage = $"Error: {ex.Message}"; - AppendOutput(ex.ToString()); - SetProgress(0, "Failed."); - } - finally - { - IsBusy = false; - } - } - - private void SetProgress(double pct, string step) - { - ProgressPercent = pct; - ProgressStep = step; - AppendOutput($"[{pct:0}%] {step}"); - } - - private void AppendOutput(string text) - { - if (!string.IsNullOrWhiteSpace(text)) - DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text; - } - -} - diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs deleted file mode 100644 index 458a86c..0000000 --- a/OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs +++ /dev/null @@ -1,276 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using OTSSignsOrchestrator.Core.Data; -using OTSSignsOrchestrator.Core.Models.DTOs; -using OTSSignsOrchestrator.Core.Models.Entities; -using OTSSignsOrchestrator.Desktop.Services; - -namespace OTSSignsOrchestrator.Desktop.ViewModels; - -/// -/// ViewModel for managing SSH host connections. -/// Allows adding, editing, testing, and removing remote Docker Swarm hosts. -/// -public partial class HostsViewModel : ObservableObject -{ - private readonly IServiceProvider _services; - - [ObservableProperty] private ObservableCollection _hosts = new(); - [ObservableProperty] private SshHost? _selectedHost; - [ObservableProperty] private bool _isEditing; - [ObservableProperty] private string _statusMessage = string.Empty; - [ObservableProperty] private bool _isBusy; - [ObservableProperty] private ObservableCollection _remoteNodes = new(); - [ObservableProperty] private string _nodesStatusMessage = string.Empty; - - // Edit form fields - [ObservableProperty] private string _editLabel = string.Empty; - [ObservableProperty] private string _editHost = string.Empty; - [ObservableProperty] private int _editPort = 22; - [ObservableProperty] private string _editUsername = string.Empty; - [ObservableProperty] private string _editPrivateKeyPath = string.Empty; - [ObservableProperty] private string _editKeyPassphrase = string.Empty; - [ObservableProperty] private string _editPassword = string.Empty; - [ObservableProperty] private bool _editUseKeyAuth = true; - private Guid? _editingHostId; - - public HostsViewModel(IServiceProvider services) - { - _services = services; - _ = LoadHostsAsync(); - } - - [RelayCommand] - private async Task LoadHostsAsync() - { - IsBusy = true; - try - { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync(); - - Hosts = new ObservableCollection(hosts); - StatusMessage = $"Loaded {hosts.Count} host(s)."; - } - catch (Exception ex) - { - StatusMessage = $"Error loading hosts: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - private void NewHost() - { - _editingHostId = null; - EditLabel = string.Empty; - EditHost = string.Empty; - EditPort = 22; - EditUsername = string.Empty; - EditPrivateKeyPath = string.Empty; - EditKeyPassphrase = string.Empty; - EditPassword = string.Empty; - EditUseKeyAuth = true; - IsEditing = true; - } - - [RelayCommand] - private void EditSelectedHost() - { - if (SelectedHost == null) return; - - _editingHostId = SelectedHost.Id; - EditLabel = SelectedHost.Label; - EditHost = SelectedHost.Host; - EditPort = SelectedHost.Port; - EditUsername = SelectedHost.Username; - EditPrivateKeyPath = SelectedHost.PrivateKeyPath ?? string.Empty; - EditKeyPassphrase = string.Empty; // Don't show existing passphrase - EditPassword = string.Empty; // Don't show existing password - EditUseKeyAuth = SelectedHost.UseKeyAuth; - IsEditing = true; - } - - [RelayCommand] - private void CancelEdit() - { - IsEditing = false; - } - - [RelayCommand] - private async Task SaveHostAsync() - { - if (string.IsNullOrWhiteSpace(EditLabel) || string.IsNullOrWhiteSpace(EditHost) || string.IsNullOrWhiteSpace(EditUsername)) - { - StatusMessage = "Label, Host, and Username are required."; - return; - } - - IsBusy = true; - try - { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - SshHost host; - if (_editingHostId.HasValue) - { - host = await db.SshHosts.FindAsync(_editingHostId.Value) - ?? throw new KeyNotFoundException("Host not found."); - - host.Label = EditLabel; - host.Host = EditHost; - host.Port = EditPort; - host.Username = EditUsername; - host.PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath; - host.UseKeyAuth = EditUseKeyAuth; - host.UpdatedAt = DateTime.UtcNow; - - if (!string.IsNullOrEmpty(EditKeyPassphrase)) - host.KeyPassphrase = EditKeyPassphrase; - if (!string.IsNullOrEmpty(EditPassword)) - host.Password = EditPassword; - } - else - { - host = new SshHost - { - Label = EditLabel, - Host = EditHost, - Port = EditPort, - Username = EditUsername, - PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath, - KeyPassphrase = string.IsNullOrEmpty(EditKeyPassphrase) ? null : EditKeyPassphrase, - Password = string.IsNullOrEmpty(EditPassword) ? null : EditPassword, - UseKeyAuth = EditUseKeyAuth - }; - db.SshHosts.Add(host); - } - - await db.SaveChangesAsync(); - - IsEditing = false; - StatusMessage = $"Host '{host.Label}' saved."; - await LoadHostsAsync(); - } - catch (Exception ex) - { - StatusMessage = $"Error saving host: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - private async Task DeleteHostAsync() - { - if (SelectedHost == null) return; - - IsBusy = true; - try - { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var host = await db.SshHosts.FindAsync(SelectedHost.Id); - if (host != null) - { - db.SshHosts.Remove(host); - await db.SaveChangesAsync(); - } - - // Disconnect if connected - var ssh = _services.GetRequiredService(); - ssh.Disconnect(SelectedHost.Id); - - StatusMessage = $"Host '{SelectedHost.Label}' deleted."; - await LoadHostsAsync(); - } - catch (Exception ex) - { - StatusMessage = $"Error deleting host: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - private async Task ListNodesAsync() - { - if (SelectedHost == null) - { - NodesStatusMessage = "Select a host first."; - return; - } - - IsBusy = true; - NodesStatusMessage = $"Listing nodes on {SelectedHost.Label}..."; - try - { - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(SelectedHost); - var nodes = await dockerCli.ListNodesAsync(); - RemoteNodes = new ObservableCollection(nodes); - NodesStatusMessage = $"Found {nodes.Count} node(s) on {SelectedHost.Label}."; - } - catch (Exception ex) - { - RemoteNodes.Clear(); - NodesStatusMessage = $"Error: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - private async Task TestConnectionAsync() - { - if (SelectedHost == null) return; - - IsBusy = true; - StatusMessage = $"Testing connection to {SelectedHost.Label}..."; - try - { - var ssh = _services.GetRequiredService(); - var (success, message) = await ssh.TestConnectionAsync(SelectedHost); - - // Update DB - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var host = await db.SshHosts.FindAsync(SelectedHost.Id); - if (host != null) - { - host.LastTestedAt = DateTime.UtcNow; - host.LastTestSuccess = success; - await db.SaveChangesAsync(); - } - - StatusMessage = success - ? $"✓ {SelectedHost.Label}: {message}" - : $"✗ {SelectedHost.Label}: {message}"; - - await LoadHostsAsync(); - } - catch (Exception ex) - { - StatusMessage = $"Connection test error: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } -} diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs deleted file mode 100644 index e85c59b..0000000 --- a/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs +++ /dev/null @@ -1,387 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Microsoft.Extensions.DependencyInjection; -using OTSSignsOrchestrator.Core.Models.DTOs; -using OTSSignsOrchestrator.Core.Models.Entities; -using OTSSignsOrchestrator.Core.Services; -using OTSSignsOrchestrator.Desktop.Models; -using OTSSignsOrchestrator.Desktop.Services; - -namespace OTSSignsOrchestrator.Desktop.ViewModels; - -/// -/// ViewModel for the instance details modal. -/// Shows admin credentials, DB credentials, and OAuth2 app details -/// with options to rotate passwords. -/// -public partial class InstanceDetailsViewModel : ObservableObject -{ - private readonly IServiceProvider _services; - - // ── Instance metadata ───────────────────────────────────────────────────── - [ObservableProperty] private string _stackName = string.Empty; - [ObservableProperty] private string _customerAbbrev = string.Empty; - [ObservableProperty] private string _hostLabel = string.Empty; - [ObservableProperty] private string _instanceUrl = string.Empty; - - // ── OTS admin credentials ───────────────────────────────────────────────── - [ObservableProperty] private string _adminUsername = string.Empty; - [ObservableProperty] private string _adminPassword = string.Empty; - [ObservableProperty] private bool _adminPasswordVisible = false; - [ObservableProperty] private string _adminPasswordDisplay = "••••••••"; - - // ── Database credentials ────────────────────────────────────────────────── - [ObservableProperty] private string _dbUsername = string.Empty; - [ObservableProperty] private string _dbPassword = string.Empty; - [ObservableProperty] private bool _dbPasswordVisible = false; - [ObservableProperty] private string _dbPasswordDisplay = "••••••••"; - - // ── OAuth2 application ──────────────────────────────────────────────────── - [ObservableProperty] private string _oAuthClientId = string.Empty; - [ObservableProperty] private string _oAuthClientSecret = string.Empty; - [ObservableProperty] private bool _oAuthSecretVisible = false; - [ObservableProperty] private string _oAuthSecretDisplay = "••••••••"; - - // ── Status ──────────────────────────────────────────────────────────────── - [ObservableProperty] private string _statusMessage = string.Empty; - [ObservableProperty] private bool _isBusy; - // ── Pending-setup inputs (shown when instance hasn't been initialised yet) ──────────── - [ObservableProperty] private bool _isPendingSetup; - [ObservableProperty] private string _initClientId = string.Empty; - [ObservableProperty] private string _initClientSecret = string.Empty; - // ── Services (for restart) ───────────────────────────────────────────────────── - [ObservableProperty] private ObservableCollection _stackServices = new(); - [ObservableProperty] private bool _isLoadingServices; - - /// - /// Callback the View wires up to show a confirmation dialog. - /// Parameters: (title, message) → returns true if the user confirmed. - /// - public Func>? ConfirmAsync { get; set; } - // Cached instance — needed by InitializeCommand to reload after setup - private LiveStackItem? _currentInstance; - public InstanceDetailsViewModel(IServiceProvider services) - { - _services = services; - } - - // ───────────────────────────────────────────────────────────────────────── - // Load - // ───────────────────────────────────────────────────────────────────────── - - /// Populates the ViewModel from a live . - public async Task LoadAsync(LiveStackItem instance) - { - _currentInstance = instance; - StackName = instance.StackName; - CustomerAbbrev = instance.CustomerAbbrev; - HostLabel = instance.HostLabel; - - IsBusy = true; - StatusMessage = "Loading credentials..."; - - try - { - using var scope = _services.CreateScope(); - var settings = scope.ServiceProvider.GetRequiredService(); - var postInit = scope.ServiceProvider.GetRequiredService(); - - // Derive the instance URL from the CMS server name template - var serverTemplate = await settings.GetAsync( - SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com"); - var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev); - InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}"; - - // ── Admin credentials ───────────────────────────────────────── - var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev); - AdminUsername = creds.AdminUsername; - SetAdminPassword(creds.AdminPassword ?? string.Empty); - - OAuthClientId = creds.OAuthClientId ?? string.Empty; - SetOAuthSecret(creds.OAuthClientSecret ?? string.Empty); - - // ── DB credentials ──────────────────────────────────────────── - var mySqlUserTemplate = await settings.GetAsync( - SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user"); - DbUsername = mySqlUserTemplate.Replace("{abbrev}", instance.CustomerAbbrev); - - var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(instance.CustomerAbbrev)); - SetDbPassword(dbPw ?? string.Empty); - - StatusMessage = creds.HasAdminPassword - ? "Credentials loaded." - : "Pending setup — enter your Xibo OAuth credentials below to initialise this instance."; - - IsPendingSetup = !creds.HasAdminPassword; - // Clear any previous init inputs when re-loading - if (IsPendingSetup) - { - InitClientId = string.Empty; - InitClientSecret = string.Empty; - } - - // ── Load stack services ─────────────────────────────────────── - await LoadServicesAsync(); - } - catch (Exception ex) - { - StatusMessage = $"Error loading credentials: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Initialise (pending setup) - // ───────────────────────────────────────────────────────────────────────── - - [RelayCommand] - private async Task InitializeAsync() - { - if (string.IsNullOrWhiteSpace(InitClientId) || string.IsNullOrWhiteSpace(InitClientSecret)) - { - StatusMessage = "Both Client ID and Client Secret are required."; - return; - } - if (_currentInstance is null) return; - - IsBusy = true; - StatusMessage = "Waiting for Xibo and running initialisation (this may take several minutes)..."; - try - { - var postInit = _services.GetRequiredService(); - await postInit.InitializeWithOAuthAsync( - CustomerAbbrev, - InstanceUrl, - InitClientId.Trim(), - InitClientSecret.Trim()); - - // Reload credentials — IsPendingSetup will flip to false - IsBusy = false; - await LoadAsync(_currentInstance); - } - catch (Exception ex) - { - StatusMessage = $"Initialisation failed: {ex.Message}"; - IsBusy = false; - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Visibility toggles - // ───────────────────────────────────────────────────────────────────────── - - [RelayCommand] - private void ToggleAdminPasswordVisibility() - { - AdminPasswordVisible = !AdminPasswordVisible; - AdminPasswordDisplay = AdminPasswordVisible - ? AdminPassword - : (AdminPassword.Length > 0 ? "••••••••" : "(not set)"); - } - - [RelayCommand] - private void ToggleDbPasswordVisibility() - { - DbPasswordVisible = !DbPasswordVisible; - DbPasswordDisplay = DbPasswordVisible - ? DbPassword - : (DbPassword.Length > 0 ? "••••••••" : "(not set)"); - } - - [RelayCommand] - private void ToggleOAuthSecretVisibility() - { - OAuthSecretVisible = !OAuthSecretVisible; - OAuthSecretDisplay = OAuthSecretVisible - ? OAuthClientSecret - : (OAuthClientSecret.Length > 0 ? "••••••••" : "(not set)"); - } - - // ───────────────────────────────────────────────────────────────────────── - // Clipboard - // ───────────────────────────────────────────────────────────────────────── - - [RelayCommand] - private async Task CopyAdminPasswordAsync() - => await CopyToClipboardAsync(AdminPassword, "Admin password"); - - [RelayCommand] - private async Task CopyDbPasswordAsync() - => await CopyToClipboardAsync(DbPassword, "DB password"); - - [RelayCommand] - private async Task CopyOAuthClientIdAsync() - => await CopyToClipboardAsync(OAuthClientId, "OAuth client ID"); - - [RelayCommand] - private async Task CopyOAuthSecretAsync() - => await CopyToClipboardAsync(OAuthClientSecret, "OAuth client secret"); - - // ───────────────────────────────────────────────────────────────────────── - // Rotation - // ───────────────────────────────────────────────────────────────────────── - - [RelayCommand] - private async Task RestartServiceAsync(ServiceInfo? service) - { - if (service is null || _currentInstance is null) return; - - if (ConfirmAsync is not null) - { - var confirmed = await ConfirmAsync( - "Restart Service", - $"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated."); - if (!confirmed) return; - } - - IsBusy = true; - StatusMessage = $"Restarting service '{service.Name}'..."; - try - { - var dockerCli = _services.GetRequiredService(); - var ok = await dockerCli.ForceUpdateServiceAsync(service.Name); - StatusMessage = ok - ? $"Service '{service.Name}' restarted successfully." - : $"Failed to restart service '{service.Name}'."; - - // Refresh service list to show updated replica status - await LoadServicesAsync(); - } - catch (Exception ex) - { - StatusMessage = $"Error restarting service: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - private async Task LoadServicesAsync() - { - if (_currentInstance is null) return; - - IsLoadingServices = true; - try - { - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(_currentInstance.Host); - var services = await dockerCli.InspectStackServicesAsync(_currentInstance.StackName); - StackServices = new ObservableCollection(services); - } - catch (Exception ex) - { - StatusMessage = $"Error loading services: {ex.Message}"; - } - finally - { - IsLoadingServices = false; - } - } - - [RelayCommand] - private async Task RotateAdminPasswordAsync() - { - if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return; - - IsBusy = true; - StatusMessage = "Rotating OTS admin password..."; - try - { - var postInit = _services.GetRequiredService(); - var newPassword = await postInit.RotateAdminPasswordAsync(CustomerAbbrev, InstanceUrl); - SetAdminPassword(newPassword); - StatusMessage = "Admin password rotated successfully."; - } - catch (Exception ex) - { - StatusMessage = $"Error rotating admin password: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - private async Task RotateDbPasswordAsync() - { - if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return; - - IsBusy = true; - StatusMessage = $"Rotating MySQL password for {StackName}..."; - try - { - var dockerCli = _services.GetRequiredService(); - var dockerSecrets = _services.GetRequiredService(); - - // We need the Host — retrieve from the HostLabel lookup - using var scope = _services.CreateScope(); - var instanceSvc = scope.ServiceProvider.GetRequiredService(); - - // Get the host from the loaded stack — caller must have set the SSH host before - var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(StackName); - if (ok) - { - // Reload DB password - var settings = scope.ServiceProvider.GetRequiredService(); - var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(CustomerAbbrev)); - SetDbPassword(dbPw ?? string.Empty); - StatusMessage = $"DB password rotated: {msg}"; - } - else - { - StatusMessage = $"DB rotation failed: {msg}"; - } - } - catch (Exception ex) - { - StatusMessage = $"Error rotating DB password: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Helpers - // ───────────────────────────────────────────────────────────────────────── - - private void SetAdminPassword(string value) - { - AdminPassword = value; - AdminPasswordVisible = false; - AdminPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)"; - } - - private void SetDbPassword(string value) - { - DbPassword = value; - DbPasswordVisible = false; - DbPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)"; - } - - private void SetOAuthSecret(string value) - { - OAuthClientSecret = value; - OAuthSecretVisible = false; - OAuthSecretDisplay = value.Length > 0 ? "••••••••" : "(not set)"; - } - - private static async Task CopyToClipboardAsync(string text, string label) - { - if (string.IsNullOrEmpty(text)) return; - var topLevel = Avalonia.Application.Current?.ApplicationLifetime is - Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime dt - ? dt.MainWindow - : null; - var clipboard = topLevel is not null ? Avalonia.Controls.TopLevel.GetTopLevel(topLevel)?.Clipboard : null; - if (clipboard is not null) - await clipboard.SetTextAsync(text); - } -} diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs deleted file mode 100644 index 60332b0..0000000 --- a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs +++ /dev/null @@ -1,627 +0,0 @@ -using System.Collections.ObjectModel; -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using CommunityToolkit.Mvvm.Messaging; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using OTSSignsOrchestrator.Core.Data; -using OTSSignsOrchestrator.Core.Models.DTOs; -using OTSSignsOrchestrator.Core.Models.Entities; -using OTSSignsOrchestrator.Core.Services; -using OTSSignsOrchestrator.Desktop.Models; -using OTSSignsOrchestrator.Desktop.Services; - -namespace OTSSignsOrchestrator.Desktop.ViewModels; - -/// -/// ViewModel for listing, viewing, and managing CMS instances. -/// All data is fetched live from Docker Swarm hosts — nothing stored locally. -/// Server operations (decommission, suspend, reactivate) go through the REST API. -/// Real-time updates arrive via SignalR → WeakReferenceMessenger. -/// -public partial class InstancesViewModel : ObservableObject, - IRecipient, - IRecipient, - IRecipient, - IRecipient -{ - private readonly IServiceProvider _services; - private readonly ILogger _logger; - private readonly IServerApiClient? _serverApi; - - [ObservableProperty] private ObservableCollection _instances = new(); - [ObservableProperty] private LiveStackItem? _selectedInstance; - [ObservableProperty] private string _statusMessage = string.Empty; - [ObservableProperty] private bool _isBusy; - [ObservableProperty] private string _filterText = string.Empty; - [ObservableProperty] private ObservableCollection _selectedServices = new(); - - // Available SSH hosts — loaded for display and used to scope operations - [ObservableProperty] private ObservableCollection _availableHosts = new(); - [ObservableProperty] private SshHost? _selectedSshHost; - - // ── P1 Authentik Banner ────────────────────────────────────────────────── - [ObservableProperty] private bool _isAuthentikP1BannerVisible; - [ObservableProperty] private string _authentikP1Message = string.Empty; - - // ── Container Logs ────────────────────────────────────────────────────── - [ObservableProperty] private ObservableCollection _logEntries = new(); - [ObservableProperty] private ObservableCollection _logServiceFilter = new(); - [ObservableProperty] private string _selectedLogService = "All Services"; - [ObservableProperty] private bool _isLogsPanelVisible; - [ObservableProperty] private bool _isLogsAutoRefresh = true; - [ObservableProperty] private bool _isLoadingLogs; - [ObservableProperty] private string _logsStatusMessage = string.Empty; - [ObservableProperty] private int _logTailLines = 200; - - private DispatcherTimer? _logRefreshTimer; - private bool _isLogRefreshRunning; - - /// Raised when the instance details modal should be opened for the given ViewModel. - public event Action? OpenDetailsRequested; - - /// - /// Callback the View wires up to show a confirmation dialog. - /// Parameters: (title, message) → returns true if the user confirmed. - /// - public Func>? ConfirmAsync { get; set; } - - /// - /// Callback the View wires up to show a multi-step confirmation dialog for decommission. - /// Parameters: (abbreviation) → returns true if confirmed through all steps. - /// - public Func>? ConfirmDecommissionAsync { get; set; } - - private string? _pendingSelectAbbrev; - - public InstancesViewModel(IServiceProvider services) - { - _services = services; - _logger = services.GetRequiredService>(); - _serverApi = services.GetService(); - - // Register for SignalR messages via WeakReferenceMessenger - WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); - - _ = RefreshAllAsync(); - } - - /// - /// Queues an abbreviation to be auto-selected once the next live refresh completes. - /// Call immediately after construction (before finishes). - /// - public void SetPendingSelection(string abbrev) - => _pendingSelectAbbrev = abbrev; - - // ── SignalR Message Handlers ───────────────────────────────────────────── - // These are called on the UI thread (SignalR handlers dispatch via Dispatcher.UIThread). - - void IRecipient.Receive(AlertRaisedMessage message) - { - var (severity, msg) = message.Value; - if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) && - msg.Contains("Authentik", StringComparison.OrdinalIgnoreCase)) - { - AuthentikP1Message = msg; - IsAuthentikP1BannerVisible = true; - } - } - - void IRecipient.Receive(InstanceStatusChangedMessage message) - { - var (customerId, status) = message.Value; - _logger.LogInformation("Instance status changed: customer={CustomerId} status={Status}", customerId, status); - StatusMessage = $"Instance {customerId} status → {status}"; - // Refresh the list to pick up the new status - _ = RefreshAllAsync(); - } - - void IRecipient.Receive(JobCreatedMessage message) - { - var (jobId, abbrev, jobType) = message.Value; - _logger.LogInformation("Job created: {JobId} type={JobType} abbrev={Abbrev}", jobId, jobType, abbrev); - StatusMessage = $"Job '{jobType}' created for {abbrev} (id: {jobId[..8]}…)"; - } - - void IRecipient.Receive(JobCompletedMessage message) - { - var (jobId, success, summary) = message.Value; - _logger.LogInformation("Job completed: {JobId} success={Success} summary={Summary}", jobId, success, summary); - StatusMessage = success - ? $"Job {jobId[..8]}… completed: {summary}" - : $"Job {jobId[..8]}… failed: {summary}"; - // Refresh the instance list to reflect changes from the completed job - _ = RefreshAllAsync(); - } - - // ── Load / Refresh ────────────────────────────────────────────────────── - - /// - /// Enumerates all SSH hosts, then calls docker stack ls on each to build the - /// live instance list. Only stacks matching *-cms-stack are shown. - /// - [RelayCommand] - private async Task LoadInstancesAsync() => await RefreshAllAsync(); - - private async Task RefreshAllAsync() - { - IsBusy = true; - StatusMessage = "Loading live instances from all hosts..."; - SelectedServices = new ObservableCollection(); - try - { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync(); - AvailableHosts = new ObservableCollection(hosts); - - var dockerCli = _services.GetRequiredService(); - var all = new List(); - var errors = new List(); - - foreach (var host in hosts) - { - try - { - dockerCli.SetHost(host); - var stacks = await dockerCli.ListStacksAsync(); - foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack"))) - { - all.Add(new LiveStackItem - { - StackName = stack.Name, - CustomerAbbrev = stack.Name[..^10], - ServiceCount = stack.ServiceCount, - Host = host, - }); - } - } - catch (Exception ex) { errors.Add($"{host.Label}: {ex.Message}"); } - } - - // Enrich with server-side customer IDs if the server API is available - if (_serverApi is not null) - { - try - { - var fleet = await _serverApi.GetFleetAsync(); - var lookup = fleet.ToDictionary(f => f.Abbreviation, f => f.CustomerId, StringComparer.OrdinalIgnoreCase); - foreach (var item in all) - { - if (lookup.TryGetValue(item.CustomerAbbrev, out var customerId)) - item.CustomerId = customerId; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not enrich instances with server fleet data"); - } - } - - if (!string.IsNullOrWhiteSpace(FilterText)) - all = all.Where(i => - i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) || - i.CustomerAbbrev.Contains(FilterText, StringComparison.OrdinalIgnoreCase) || - i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList(); - - Instances = new ObservableCollection(all); - - // Auto-select a pending instance (e.g. just deployed from Create Instance page) - if (_pendingSelectAbbrev is not null) - { - SelectedInstance = all.FirstOrDefault(i => - i.CustomerAbbrev.Equals(_pendingSelectAbbrev, StringComparison.OrdinalIgnoreCase)); - _pendingSelectAbbrev = null; - } - - var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s)."; - if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}"; - StatusMessage = msg; - } - catch (Exception ex) { StatusMessage = $"Error: {ex.Message}"; } - finally { IsBusy = false; } - } - - [RelayCommand] - private async Task InspectInstanceAsync() - { - if (SelectedInstance == null) return; - IsBusy = true; - StatusMessage = $"Inspecting '{SelectedInstance.StackName}'..."; - try - { - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(SelectedInstance.Host); - var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); - SelectedServices = new ObservableCollection(services); - StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'."; - - // Populate service filter dropdown and show logs panel - var filterItems = new List { "All Services" }; - filterItems.AddRange(services.Select(s => s.Name)); - LogServiceFilter = new ObservableCollection(filterItems); - SelectedLogService = "All Services"; - IsLogsPanelVisible = true; - - // Fetch initial logs and start auto-refresh - await FetchLogsInternalAsync(); - StartLogAutoRefresh(); - } - catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; } - finally { IsBusy = false; } - } - - // ── Container Log Commands ────────────────────────────────────────────── - - [RelayCommand] - private async Task RefreshLogsAsync() - { - await FetchLogsInternalAsync(); - } - - [RelayCommand] - private void ToggleLogsAutoRefresh() - { - IsLogsAutoRefresh = !IsLogsAutoRefresh; - if (IsLogsAutoRefresh) - StartLogAutoRefresh(); - else - StopLogAutoRefresh(); - } - - [RelayCommand] - private void CloseLogsPanel() - { - StopLogAutoRefresh(); - IsLogsPanelVisible = false; - LogEntries = new ObservableCollection(); - LogsStatusMessage = string.Empty; - } - - partial void OnSelectedLogServiceChanged(string value) - { - // When user changes the service filter, refresh logs immediately - if (IsLogsPanelVisible) - _ = FetchLogsInternalAsync(); - } - - private async Task FetchLogsInternalAsync() - { - if (SelectedInstance == null || _isLogRefreshRunning) return; - - _isLogRefreshRunning = true; - IsLoadingLogs = true; - try - { - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(SelectedInstance.Host); - - string? serviceFilter = SelectedLogService == "All Services" ? null : SelectedLogService; - var entries = await dockerCli.GetServiceLogsAsync( - SelectedInstance.StackName, serviceFilter, LogTailLines); - - LogEntries = new ObservableCollection(entries); - LogsStatusMessage = $"{entries.Count} log line(s) · last fetched {DateTime.Now:HH:mm:ss}"; - } - catch (Exception ex) - { - LogsStatusMessage = $"Error fetching logs: {ex.Message}"; - } - finally - { - IsLoadingLogs = false; - _isLogRefreshRunning = false; - } - } - - private void StartLogAutoRefresh() - { - StopLogAutoRefresh(); - if (!IsLogsAutoRefresh) return; - - _logRefreshTimer = new DispatcherTimer - { - Interval = TimeSpan.FromSeconds(5) - }; - _logRefreshTimer.Tick += async (_, _) => - { - if (IsLogsPanelVisible && IsLogsAutoRefresh && !_isLogRefreshRunning) - await FetchLogsInternalAsync(); - }; - _logRefreshTimer.Start(); - } - - private void StopLogAutoRefresh() - { - _logRefreshTimer?.Stop(); - _logRefreshTimer = null; - } - - // ── Restart Commands ──────────────────────────────────────────────── - - [RelayCommand] - private async Task RestartStackAsync() - { - if (SelectedInstance == null) return; - - if (ConfirmAsync is not null) - { - var confirmed = await ConfirmAsync( - "Restart Stack", - $"Are you sure you want to restart all services in '{SelectedInstance.StackName}'?\n\nThis will force-update every service in the stack, causing brief downtime."); - if (!confirmed) return; - } - - IsBusy = true; - StatusMessage = $"Restarting all services in '{SelectedInstance.StackName}'..."; - try - { - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(SelectedInstance.Host); - - var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); - var failures = new List(); - - for (var i = 0; i < services.Count; i++) - { - var svc = services[i]; - StatusMessage = $"Restarting service {i + 1}/{services.Count}: {svc.Name}..."; - var ok = await dockerCli.ForceUpdateServiceAsync(svc.Name); - if (!ok) failures.Add(svc.Name); - } - - StatusMessage = failures.Count == 0 - ? $"All {services.Count} service(s) in '{SelectedInstance.StackName}' restarted successfully." - : $"Restarted with errors — failed services: {string.Join(", ", failures)}"; - } - catch (Exception ex) { StatusMessage = $"Error restarting stack: {ex.Message}"; } - finally { IsBusy = false; } - } - - [RelayCommand] - private async Task RestartServiceAsync(ServiceInfo? service) - { - if (service is null || SelectedInstance is null) return; - - if (ConfirmAsync is not null) - { - var confirmed = await ConfirmAsync( - "Restart Service", - $"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated."); - if (!confirmed) return; - } - - IsBusy = true; - StatusMessage = $"Restarting service '{service.Name}'..."; - try - { - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(SelectedInstance.Host); - var ok = await dockerCli.ForceUpdateServiceAsync(service.Name); - StatusMessage = ok - ? $"Service '{service.Name}' restarted successfully." - : $"Failed to restart service '{service.Name}'."; - - // Refresh services to show updated replica status - var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); - SelectedServices = new ObservableCollection(services); - } - catch (Exception ex) { StatusMessage = $"Error restarting service: {ex.Message}"; } - finally { IsBusy = false; } - } - - [RelayCommand] - private async Task DeleteInstanceAsync() - { - if (SelectedInstance == null) return; - IsBusy = true; - StatusMessage = $"Deleting {SelectedInstance.StackName}..."; - try - { - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(SelectedInstance.Host); - var dockerSecrets = _services.GetRequiredService(); - dockerSecrets.SetHost(SelectedInstance.Host); - using var scope = _services.CreateScope(); - var instanceSvc = scope.ServiceProvider.GetRequiredService(); - var result = await instanceSvc.DeleteInstanceAsync( - SelectedInstance.StackName, SelectedInstance.CustomerAbbrev); - StatusMessage = result.Success - ? $"Instance '{SelectedInstance.StackName}' deleted." - : $"Delete failed: {result.ErrorMessage}"; - await RefreshAllAsync(); - } - catch (Exception ex) { StatusMessage = $"Error deleting: {ex.Message}"; } - finally { IsBusy = false; } - } - - [RelayCommand] - private async Task RotateMySqlPasswordAsync() - { - if (SelectedInstance == null) return; - IsBusy = true; - StatusMessage = $"Rotating MySQL password for {SelectedInstance.StackName}..."; - try - { - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(SelectedInstance.Host); - var dockerSecrets = _services.GetRequiredService(); - dockerSecrets.SetHost(SelectedInstance.Host); - using var scope = _services.CreateScope(); - var instanceSvc = scope.ServiceProvider.GetRequiredService(); - var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(SelectedInstance.StackName); - StatusMessage = ok ? $"Done: {msg}" : $"Failed: {msg}"; - await RefreshAllAsync(); - } - catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; } - finally { IsBusy = false; } - } - - // ── P1 Banner Commands ──────────────────────────────────────────────── - - [RelayCommand] - private void DismissP1Banner() - { - IsAuthentikP1BannerVisible = false; - AuthentikP1Message = string.Empty; - } - - /// - /// Called from a SignalR AlertRaised handler (runs on a background thread). - /// CRITICAL: wraps all property updates with to - /// avoid silent cross-thread exceptions in Avalonia. - /// - public void HandleAlertRaised(string severity, string message) - { - if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) && - message.Contains("Authentik", StringComparison.OrdinalIgnoreCase)) - { - Dispatcher.UIThread.InvokeAsync(() => - { - AuthentikP1Message = message; - IsAuthentikP1BannerVisible = true; - }); - } - } - - // ── Server-side Job Commands (decommission, suspend, reactivate) ──────── - // Desktop has NO direct infrastructure access — all operations go through the server REST API. - - [RelayCommand] - private async Task DecommissionAsync(LiveStackItem? instance) - { - instance ??= SelectedInstance; - if (instance is null) return; - - if (instance.CustomerId is null) - { - StatusMessage = "Cannot decommission: no server-side customer ID available for this instance."; - return; - } - - // Multi-step confirmation: user must type the abbreviation to confirm - if (ConfirmDecommissionAsync is not null) - { - var confirmed = await ConfirmDecommissionAsync(instance.CustomerAbbrev); - if (!confirmed) return; - } - else if (ConfirmAsync is not null) - { - var confirmed = await ConfirmAsync( - "Decommission Instance", - $"Are you sure you want to decommission '{instance.CustomerAbbrev}'?\n\n" + - "This will:\n" + - " • Remove all Docker services and stack\n" + - " • Delete Docker secrets\n" + - " • Remove NFS volumes and data\n" + - " • Revoke Authentik provider\n" + - " • Mark the customer as decommissioned\n\n" + - "This action is IRREVERSIBLE."); - if (!confirmed) return; - } - - await CreateServerJobAsync(instance, "decommission"); - } - - [RelayCommand] - private async Task SuspendInstanceAsync(LiveStackItem? instance) - { - instance ??= SelectedInstance; - if (instance is null) return; - - if (instance.CustomerId is null) - { - StatusMessage = "Cannot suspend: no server-side customer ID available for this instance."; - return; - } - - if (ConfirmAsync is not null) - { - var confirmed = await ConfirmAsync( - "Suspend Instance", - $"Are you sure you want to suspend '{instance.CustomerAbbrev}'?\n\n" + - "The instance will be scaled to zero replicas. Data will be preserved."); - if (!confirmed) return; - } - - await CreateServerJobAsync(instance, "suspend"); - } - - [RelayCommand] - private async Task ReactivateInstanceAsync(LiveStackItem? instance) - { - instance ??= SelectedInstance; - if (instance is null) return; - - if (instance.CustomerId is null) - { - StatusMessage = "Cannot reactivate: no server-side customer ID available for this instance."; - return; - } - - await CreateServerJobAsync(instance, "reactivate"); - } - - private async Task CreateServerJobAsync(LiveStackItem instance, string jobType) - { - if (_serverApi is null) - { - StatusMessage = "Server API client is not configured."; - return; - } - - IsBusy = true; - StatusMessage = $"Requesting '{jobType}' for {instance.CustomerAbbrev}..."; - try - { - var response = await _serverApi.CreateJobAsync( - new CreateJobRequest(instance.CustomerId!.Value, jobType, null)); - StatusMessage = $"Job '{jobType}' created (id: {response.Id.ToString()[..8]}…). Status: {response.Status}"; - _logger.LogInformation("Server job created: {JobId} type={JobType} customer={CustomerId}", - response.Id, jobType, instance.CustomerId); - } - catch (Refit.ApiException ex) - { - StatusMessage = $"Server error creating '{jobType}' job: {ex.StatusCode} — {ex.Content}"; - _logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId); - } - catch (Exception ex) - { - StatusMessage = $"Error creating '{jobType}' job: {ex.Message}"; - _logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId); - } - finally { IsBusy = false; } - } - - // ── Details ───────────────────────────────────────────────────────────── - - [RelayCommand] - private async Task OpenDetailsAsync() - { - if (SelectedInstance == null) return; - - IsBusy = true; - StatusMessage = $"Loading details for '{SelectedInstance.StackName}'..."; - try - { - // Set the SSH host on singleton Docker services so modal operations target the right host - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(SelectedInstance.Host); - var dockerSecrets = _services.GetRequiredService(); - dockerSecrets.SetHost(SelectedInstance.Host); - - var detailsVm = _services.GetRequiredService(); - await detailsVm.LoadAsync(SelectedInstance); - - OpenDetailsRequested?.Invoke(detailsVm); - StatusMessage = string.Empty; - } - catch (Exception ex) { StatusMessage = $"Error opening details: {ex.Message}"; } - finally { IsBusy = false; } - } -} diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs deleted file mode 100644 index 6831499..0000000 --- a/OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using OTSSignsOrchestrator.Core.Data; -using OTSSignsOrchestrator.Core.Models.Entities; - -namespace OTSSignsOrchestrator.Desktop.ViewModels; - -/// -/// ViewModel for viewing operation logs. -/// -public partial class LogsViewModel : ObservableObject -{ - private readonly IServiceProvider _services; - - [ObservableProperty] private ObservableCollection _logs = new(); - [ObservableProperty] private string _statusMessage = string.Empty; - [ObservableProperty] private bool _isBusy; - [ObservableProperty] private int _maxEntries = 100; - - public LogsViewModel(IServiceProvider services) - { - _services = services; - _ = LoadLogsAsync(); - } - - [RelayCommand] - private async Task LoadLogsAsync() - { - IsBusy = true; - try - { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var items = await db.OperationLogs - .OrderByDescending(l => l.Timestamp) - .Take(MaxEntries) - .ToListAsync(); - - Logs = new ObservableCollection(items); - StatusMessage = $"Showing {items.Count} log entries."; - } - catch (Exception ex) - { - StatusMessage = $"Error loading logs: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } -} diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/MainWindowViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/MainWindowViewModel.cs deleted file mode 100644 index 396cdf6..0000000 --- a/OTSSignsOrchestrator.Desktop/ViewModels/MainWindowViewModel.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; - -namespace OTSSignsOrchestrator.Desktop.ViewModels; - -public partial class MainWindowViewModel : ObservableObject -{ - private readonly IServiceProvider _services; - - [ObservableProperty] - private ObservableObject? _currentView; - - [ObservableProperty] - private string _selectedNav = "Hosts"; - - [ObservableProperty] - private string _statusMessage = "Ready"; - - public ObservableCollection NavItems { get; } = new() - { - "Hosts", - "Instances", - "Create Instance", - "Secrets", - "Settings", - "Logs" - }; - - public MainWindowViewModel(IServiceProvider services) - { - _services = services; - NavigateTo("Hosts"); - } - - partial void OnSelectedNavChanged(string value) - { - NavigateTo(value); - } - - [RelayCommand] - private void NavigateTo(string page) - { - CurrentView = page switch - { - "Hosts" => (ObservableObject)_services.GetService(typeof(HostsViewModel))!, - "Instances" => (ObservableObject)_services.GetService(typeof(InstancesViewModel))!, - "Create Instance" => (ObservableObject)_services.GetService(typeof(CreateInstanceViewModel))!, - "Secrets" => (ObservableObject)_services.GetService(typeof(SecretsViewModel))!, - "Settings" => (ObservableObject)_services.GetService(typeof(SettingsViewModel))!, - "Logs" => (ObservableObject)_services.GetService(typeof(LogsViewModel))!, - _ => CurrentView - }; - } - - /// - /// Navigates to the Instances page and auto-selects the instance with the given abbreviation - /// once the live refresh completes. - /// - public void NavigateToInstancesWithSelection(string abbrev) - { - SelectedNav = "Instances"; // triggers OnSelectedNavChanged → NavigateTo("Instances") - if (CurrentView is InstancesViewModel instancesVm) - instancesVm.SetPendingSelection(abbrev); - } - - public void SetStatus(string message) - { - StatusMessage = message; - } -} diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/SecretsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/SecretsViewModel.cs deleted file mode 100644 index e8da9cc..0000000 --- a/OTSSignsOrchestrator.Desktop/ViewModels/SecretsViewModel.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using OTSSignsOrchestrator.Core.Data; -using OTSSignsOrchestrator.Core.Models.Entities; -using OTSSignsOrchestrator.Core.Services; -using OTSSignsOrchestrator.Desktop.Services; - -namespace OTSSignsOrchestrator.Desktop.ViewModels; - -/// -/// ViewModel for viewing and managing Docker Swarm secrets on a remote host. -/// -public partial class SecretsViewModel : ObservableObject -{ - private readonly IServiceProvider _services; - - [ObservableProperty] private ObservableCollection _secrets = new(); - [ObservableProperty] private ObservableCollection _availableHosts = new(); - [ObservableProperty] private SshHost? _selectedSshHost; - [ObservableProperty] private string _statusMessage = string.Empty; - [ObservableProperty] private bool _isBusy; - - public SecretsViewModel(IServiceProvider services) - { - _services = services; - _ = LoadHostsAsync(); - } - - private async Task LoadHostsAsync() - { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync(); - AvailableHosts = new ObservableCollection(hosts); - } - - [RelayCommand] - private async Task LoadSecretsAsync() - { - if (SelectedSshHost == null) - { - StatusMessage = "Select an SSH host first."; - return; - } - - IsBusy = true; - try - { - var secretsSvc = _services.GetRequiredService(); - secretsSvc.SetHost(SelectedSshHost); - - var items = await secretsSvc.ListSecretsAsync(); - Secrets = new ObservableCollection(items); - StatusMessage = $"Found {items.Count} secret(s) on {SelectedSshHost.Label}."; - } - catch (Exception ex) - { - StatusMessage = $"Error: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } -} diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs deleted file mode 100644 index 2ea0d1b..0000000 --- a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs +++ /dev/null @@ -1,584 +0,0 @@ -using System.Collections.ObjectModel; -using System.Text.Json; -using System.Text.Json.Nodes; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using OTSSignsOrchestrator.Core.Configuration; -using OTSSignsOrchestrator.Core.Models.DTOs; -using OTSSignsOrchestrator.Core.Services; - -namespace OTSSignsOrchestrator.Desktop.ViewModels; - -/// -/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, NFS, -/// and Instance Defaults configuration, persisted via SettingsService. -/// -public partial class SettingsViewModel : ObservableObject -{ - private readonly IServiceProvider _services; - - [ObservableProperty] private string _statusMessage = string.Empty; - [ObservableProperty] private bool _isBusy; - - // ── Git ────────────────────────────────────────────────────────────────── - [ObservableProperty] private string _gitRepoUrl = string.Empty; - [ObservableProperty] private string _gitRepoPat = string.Empty; - - // ── MySQL Admin ───────────────────────────────────────────────────────── - [ObservableProperty] private string _mySqlHost = string.Empty; - [ObservableProperty] private string _mySqlPort = "3306"; - [ObservableProperty] private string _mySqlAdminUser = string.Empty; - [ObservableProperty] private string _mySqlAdminPassword = string.Empty; - - // ── SMTP ──────────────────────────────────────────────────────────────── - [ObservableProperty] private string _smtpServer = string.Empty; - [ObservableProperty] private string _smtpUsername = string.Empty; - [ObservableProperty] private string _smtpPassword = string.Empty; - [ObservableProperty] private bool _smtpUseTls = true; - [ObservableProperty] private bool _smtpUseStartTls = true; - [ObservableProperty] private string _smtpRewriteDomain = string.Empty; - [ObservableProperty] private string _smtpHostname = string.Empty; - [ObservableProperty] private string _smtpFromLineOverride = "NO"; - - // ── Pangolin ──────────────────────────────────────────────────────────── - [ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net"; - - // ── NFS ───────────────────────────────────────────────────────────────── - [ObservableProperty] private string _nfsServer = string.Empty; - [ObservableProperty] private string _nfsExport = string.Empty; - [ObservableProperty] private string _nfsExportFolder = string.Empty; - [ObservableProperty] private string _nfsOptions = string.Empty; - - // ── Instance Defaults ─────────────────────────────────────────────────── - [ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3"; - [ObservableProperty] private string _defaultNewtImage = "fosrl/newt"; - [ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine"; - [ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart"; - [ObservableProperty] private string _defaultCmsServerNameTemplate = "app.ots-signs.com"; - [ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom"; - [ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db"; - [ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user"; - [ObservableProperty] private string _defaultPhpPostMaxSize = "10G"; - [ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G"; - [ObservableProperty] private string _defaultPhpMaxExecutionTime = "600"; - - // ── Bitwarden Secrets Manager ───────────────────────────────── - [ObservableProperty] private string _bitwardenIdentityUrl = "https://identity.bitwarden.com"; - [ObservableProperty] private string _bitwardenApiUrl = "https://api.bitwarden.com"; - [ObservableProperty] private string _bitwardenAccessToken = string.Empty; - [ObservableProperty] private string _bitwardenOrganizationId = string.Empty; - [ObservableProperty] private string _bitwardenProjectId = string.Empty; - [ObservableProperty] private string _bitwardenInstanceProjectId = string.Empty; - - // ── Authentik (SAML IdP) ──────────────────────────────────────── - [ObservableProperty] private string _authentikUrl = string.Empty; - [ObservableProperty] private string _authentikApiKey = string.Empty; - [ObservableProperty] private string _authentikAuthorizationFlowSlug = string.Empty; - [ObservableProperty] private string _authentikInvalidationFlowSlug = string.Empty; - [ObservableProperty] private string _authentikSigningKeypairId = string.Empty; - [ObservableProperty] private string _authentikStatusMessage = string.Empty; - [ObservableProperty] private bool _isAuthentikBusy; - - // Dropdown collections for Authentik flows / keypairs - public ObservableCollection AuthentikAuthorizationFlows { get; } = new(); - public ObservableCollection AuthentikInvalidationFlows { get; } = new(); - public ObservableCollection AuthentikKeypairs { get; } = new(); - - [ObservableProperty] private AuthentikFlowItem? _selectedAuthorizationFlow; - [ObservableProperty] private AuthentikFlowItem? _selectedInvalidationFlow; - [ObservableProperty] private AuthentikKeypairItem? _selectedSigningKeypair; - - // ── Xibo Bootstrap OAuth2 ───────────────────────────────────── - [ObservableProperty] private string _xiboBootstrapClientId = string.Empty; - [ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty; - - public SettingsViewModel(IServiceProvider services) - { - _services = services; - _ = LoadAsync(); - } - - /// Whether Bitwarden is configured and reachable. - [ObservableProperty] private bool _isBitwardenConfigured; - - [RelayCommand] - private Task LoadAsync() => LoadCoreAsync(skipBitwarden: false); - - private async Task LoadCoreAsync(bool skipBitwarden) - { - IsBusy = true; - try - { - if (!skipBitwarden) - { - // ── Load Bitwarden bootstrap config (IOptionsMonitor picks up appsettings.json changes) ── - var bwOptions = _services.GetRequiredService>().CurrentValue; - BitwardenIdentityUrl = bwOptions.IdentityUrl; - BitwardenApiUrl = bwOptions.ApiUrl; - BitwardenAccessToken = bwOptions.AccessToken; - BitwardenOrganizationId = bwOptions.OrganizationId; - BitwardenProjectId = bwOptions.ProjectId; - BitwardenInstanceProjectId = bwOptions.InstanceProjectId; - } - - IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken) - && !string.IsNullOrWhiteSpace(BitwardenOrganizationId); - - if (!IsBitwardenConfigured) - { - StatusMessage = "Bitwarden is not configured. Fill in the Bitwarden section and save to get started."; - return; - } - - // ── Load all other settings from Bitwarden ── - using var scope = _services.CreateScope(); - var svc = scope.ServiceProvider.GetRequiredService(); - svc.InvalidateCache(); - - // Git - GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty); - GitRepoPat = await svc.GetAsync(SettingsService.GitRepoPat, string.Empty); - - // MySQL - MySqlHost = await svc.GetAsync(SettingsService.MySqlHost, string.Empty); - MySqlPort = await svc.GetAsync(SettingsService.MySqlPort, "3306"); - MySqlAdminUser = await svc.GetAsync(SettingsService.MySqlAdminUser, string.Empty); - MySqlAdminPassword = await svc.GetAsync(SettingsService.MySqlAdminPassword, string.Empty); - - // SMTP - SmtpServer = await svc.GetAsync(SettingsService.SmtpServer, string.Empty); - SmtpUsername = await svc.GetAsync(SettingsService.SmtpUsername, string.Empty); - SmtpPassword = await svc.GetAsync(SettingsService.SmtpPassword, string.Empty); - SmtpUseTls = (await svc.GetAsync(SettingsService.SmtpUseTls, "YES")) == "YES"; - SmtpUseStartTls = (await svc.GetAsync(SettingsService.SmtpUseStartTls, "YES")) == "YES"; - SmtpRewriteDomain = await svc.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty); - SmtpHostname = await svc.GetAsync(SettingsService.SmtpHostname, string.Empty); - SmtpFromLineOverride = await svc.GetAsync(SettingsService.SmtpFromLineOverride, "NO"); - - // Pangolin - PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"); - - // NFS - NfsServer = await svc.GetAsync(SettingsService.NfsServer, string.Empty); - NfsExport = await svc.GetAsync(SettingsService.NfsExport, string.Empty); - NfsExportFolder = await svc.GetAsync(SettingsService.NfsExportFolder, string.Empty); - NfsOptions = await svc.GetAsync(SettingsService.NfsOptions, string.Empty); - - // Instance Defaults - DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"); - DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"); - DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"); - DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart"); - DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com"); - DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom"); - DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db"); - DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user"); - DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"); - DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"); - DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); - - // Authentik - AuthentikUrl = await svc.GetAsync(SettingsService.AuthentikUrl, string.Empty); - AuthentikApiKey = await svc.GetAsync(SettingsService.AuthentikApiKey, string.Empty); - AuthentikAuthorizationFlowSlug = await svc.GetAsync(SettingsService.AuthentikAuthorizationFlowSlug, string.Empty); - AuthentikInvalidationFlowSlug = await svc.GetAsync(SettingsService.AuthentikInvalidationFlowSlug, string.Empty); - AuthentikSigningKeypairId = await svc.GetAsync(SettingsService.AuthentikSigningKeypairId, string.Empty); - - // If Authentik URL + key are configured, try loading dropdowns - if (!string.IsNullOrWhiteSpace(AuthentikUrl) && !string.IsNullOrWhiteSpace(AuthentikApiKey)) - await FetchAuthentikDropdownsInternalAsync(); - - // Xibo Bootstrap - XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty); - XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty); - - StatusMessage = "Settings loaded from Bitwarden."; - } - catch (Exception ex) - { - StatusMessage = $"Error loading settings: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - private async Task SaveBitwardenLocalAsync() - { - IsBusy = true; - try - { - await SaveBitwardenConfigToFileAsync(); - - IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken) - && !string.IsNullOrWhiteSpace(BitwardenOrganizationId); - - StatusMessage = IsBitwardenConfigured - ? "Bitwarden config saved to appsettings.json." - : "Bitwarden config saved. Fill in Access Token and Org ID to enable all settings."; - } - catch (Exception ex) - { - StatusMessage = $"Error saving Bitwarden config: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - private async Task PullFromBitwardenAsync() - { - await LoadCoreAsync(skipBitwarden: true); - } - - [RelayCommand] - private async Task PushToBitwardenAsync() - { - IsBusy = true; - try - { - if (!IsBitwardenConfigured) - { - StatusMessage = "Bitwarden is not configured. Save Bitwarden config first."; - return; - } - - using var scope = _services.CreateScope(); - var svc = scope.ServiceProvider.GetRequiredService(); - svc.InvalidateCache(); - - var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)> - { - // Git - (SettingsService.GitRepoUrl, NullIfEmpty(GitRepoUrl), SettingsService.CatGit, false), - (SettingsService.GitRepoPat, NullIfEmpty(GitRepoPat), SettingsService.CatGit, true), - - // MySQL - (SettingsService.MySqlHost, NullIfEmpty(MySqlHost), SettingsService.CatMySql, false), - (SettingsService.MySqlPort, MySqlPort, SettingsService.CatMySql, false), - (SettingsService.MySqlAdminUser, NullIfEmpty(MySqlAdminUser), SettingsService.CatMySql, false), - (SettingsService.MySqlAdminPassword, NullIfEmpty(MySqlAdminPassword), SettingsService.CatMySql, true), - - // SMTP - (SettingsService.SmtpServer, NullIfEmpty(SmtpServer), SettingsService.CatSmtp, false), - (SettingsService.SmtpUsername, NullIfEmpty(SmtpUsername), SettingsService.CatSmtp, false), - (SettingsService.SmtpPassword, NullIfEmpty(SmtpPassword), SettingsService.CatSmtp, true), - (SettingsService.SmtpUseTls, SmtpUseTls ? "YES" : "NO", SettingsService.CatSmtp, false), - (SettingsService.SmtpUseStartTls, SmtpUseStartTls ? "YES" : "NO", SettingsService.CatSmtp, false), - (SettingsService.SmtpRewriteDomain, NullIfEmpty(SmtpRewriteDomain), SettingsService.CatSmtp, false), - (SettingsService.SmtpHostname, NullIfEmpty(SmtpHostname), SettingsService.CatSmtp, false), - (SettingsService.SmtpFromLineOverride, SmtpFromLineOverride, SettingsService.CatSmtp, false), - - // Pangolin - (SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false), - - // NFS - (SettingsService.NfsServer, NullIfEmpty(NfsServer), SettingsService.CatNfs, false), - (SettingsService.NfsExport, NullIfEmpty(NfsExport), SettingsService.CatNfs, false), - (SettingsService.NfsExportFolder, NullIfEmpty(NfsExportFolder), SettingsService.CatNfs, false), - (SettingsService.NfsOptions, NullIfEmpty(NfsOptions), SettingsService.CatNfs, false), - - // Instance Defaults - (SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false), - (SettingsService.DefaultNewtImage, DefaultNewtImage, SettingsService.CatDefaults, false), - (SettingsService.DefaultMemcachedImage, DefaultMemcachedImage, SettingsService.CatDefaults, false), - (SettingsService.DefaultQuickChartImage, DefaultQuickChartImage, SettingsService.CatDefaults, false), - (SettingsService.DefaultCmsServerNameTemplate, DefaultCmsServerNameTemplate, SettingsService.CatDefaults, false), - (SettingsService.DefaultThemeHostPath, DefaultThemeHostPath, SettingsService.CatDefaults, false), - (SettingsService.DefaultMySqlDbTemplate, DefaultMySqlDbTemplate, SettingsService.CatDefaults, false), - (SettingsService.DefaultMySqlUserTemplate, DefaultMySqlUserTemplate, SettingsService.CatDefaults, false), - (SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false), - (SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false), - (SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false), - - // Authentik - (SettingsService.AuthentikUrl, NullIfEmpty(AuthentikUrl), SettingsService.CatAuthentik, false), - (SettingsService.AuthentikApiKey, NullIfEmpty(AuthentikApiKey), SettingsService.CatAuthentik, true), - (SettingsService.AuthentikAuthorizationFlowSlug, NullIfEmpty(SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug), SettingsService.CatAuthentik, false), - (SettingsService.AuthentikInvalidationFlowSlug, NullIfEmpty(SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug), SettingsService.CatAuthentik, false), - (SettingsService.AuthentikSigningKeypairId, NullIfEmpty(SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId), SettingsService.CatAuthentik, false), - - // Xibo Bootstrap - (SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false), - (SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true), - }; - - await svc.SaveManyAsync(settings); - StatusMessage = "Settings pushed to Bitwarden."; - } - catch (Exception ex) - { - StatusMessage = $"Error saving settings: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - private async Task TestBitwardenConnectionAsync() - { - if (string.IsNullOrWhiteSpace(BitwardenAccessToken) || string.IsNullOrWhiteSpace(BitwardenOrganizationId)) - { - StatusMessage = "Bitwarden Access Token and Organization ID are required."; - return; - } - - IsBusy = true; - StatusMessage = "Saving Bitwarden config and testing connection..."; - try - { - // Save to appsettings.json first so the service picks up fresh values - await SaveBitwardenConfigToFileAsync(); - - using var scope = _services.CreateScope(); - var bws = scope.ServiceProvider.GetRequiredService(); - var secrets = await bws.ListSecretsAsync(); - IsBitwardenConfigured = true; - StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project."; - } - catch (Exception ex) - { - IsBitwardenConfigured = false; - StatusMessage = $"Bitwarden connection failed: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Authentik: save, test, fetch dropdowns - // ───────────────────────────────────────────────────────────────────────── - - [RelayCommand] - private async Task SaveAndTestAuthentikAsync() - { - if (string.IsNullOrWhiteSpace(AuthentikUrl) || string.IsNullOrWhiteSpace(AuthentikApiKey)) - { - AuthentikStatusMessage = "Authentik URL and API Token are required."; - return; - } - - IsAuthentikBusy = true; - AuthentikStatusMessage = "Saving Authentik settings and testing connection..."; - try - { - // Persist URL + API key first so subsequent calls work - using var scope = _services.CreateScope(); - var svc = scope.ServiceProvider.GetRequiredService(); - await svc.SetAsync(SettingsService.AuthentikUrl, AuthentikUrl.Trim(), SettingsService.CatAuthentik); - await svc.SetAsync(SettingsService.AuthentikApiKey, AuthentikApiKey.Trim(), SettingsService.CatAuthentik, isSensitive: true); - - var authentik = scope.ServiceProvider.GetRequiredService(); - var (ok, msg) = await authentik.TestConnectionAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim()); - - if (!ok) - { - AuthentikStatusMessage = $"Connection failed: {msg}"; - return; - } - - AuthentikStatusMessage = "Connected — loading flows and keypairs..."; - - // Now fetch dropdowns - await FetchAuthentikDropdownsInternalAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim()); - - // Save selected flow/keypair values - var authSlug = SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug; - var invalSlug = SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug; - var kpId = SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId; - - await svc.SetAsync(SettingsService.AuthentikAuthorizationFlowSlug, authSlug, SettingsService.CatAuthentik); - await svc.SetAsync(SettingsService.AuthentikInvalidationFlowSlug, invalSlug, SettingsService.CatAuthentik); - await svc.SetAsync(SettingsService.AuthentikSigningKeypairId, kpId, SettingsService.CatAuthentik); - - AuthentikStatusMessage = $"Authentik connected. {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s) loaded."; - } - catch (Exception ex) - { - AuthentikStatusMessage = $"Error: {ex.Message}"; - } - finally - { - IsAuthentikBusy = false; - } - } - - [RelayCommand] - private async Task FetchAuthentikDropdownsAsync() - { - IsAuthentikBusy = true; - AuthentikStatusMessage = "Fetching flows and keypairs from Authentik..."; - try - { - await FetchAuthentikDropdownsInternalAsync(); - AuthentikStatusMessage = $"Loaded {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s)."; - } - catch (Exception ex) - { - AuthentikStatusMessage = $"Error fetching data: {ex.Message}"; - } - finally - { - IsAuthentikBusy = false; - } - } - - private async Task FetchAuthentikDropdownsInternalAsync( - string? overrideUrl = null, string? overrideApiKey = null) - { - using var scope = _services.CreateScope(); - var authentik = scope.ServiceProvider.GetRequiredService(); - - var flows = await authentik.ListFlowsAsync(overrideUrl, overrideApiKey); - var keypairs = await authentik.ListKeypairsAsync(overrideUrl, overrideApiKey); - - // Populate authorization flows (designation = "authorization") - AuthentikAuthorizationFlows.Clear(); - foreach (var f in flows.Where(f => f.Designation == "authorization")) - AuthentikAuthorizationFlows.Add(f); - - // Populate invalidation flows (designation = "invalidation") - AuthentikInvalidationFlows.Clear(); - foreach (var f in flows.Where(f => f.Designation == "invalidation")) - AuthentikInvalidationFlows.Add(f); - - // Populate keypairs - AuthentikKeypairs.Clear(); - // Add a "None" option - AuthentikKeypairs.Add(new AuthentikKeypairItem { Pk = "", Name = "(none)" }); - foreach (var k in keypairs) - AuthentikKeypairs.Add(k); - - // Select items matching saved slugs - SelectedAuthorizationFlow = AuthentikAuthorizationFlows - .FirstOrDefault(f => string.Equals(f.Slug, AuthentikAuthorizationFlowSlug, StringComparison.OrdinalIgnoreCase)) - ?? AuthentikAuthorizationFlows.FirstOrDefault(f => f.Slug == "default-provider-authorization-implicit-consent") - ?? AuthentikAuthorizationFlows.FirstOrDefault(); - - SelectedInvalidationFlow = AuthentikInvalidationFlows - .FirstOrDefault(f => string.Equals(f.Slug, AuthentikInvalidationFlowSlug, StringComparison.OrdinalIgnoreCase)) - ?? AuthentikInvalidationFlows.FirstOrDefault(f => f.Slug == "default-provider-invalidation-flow") - ?? AuthentikInvalidationFlows.FirstOrDefault(); - - SelectedSigningKeypair = string.IsNullOrWhiteSpace(AuthentikSigningKeypairId) - ? AuthentikKeypairs.First() // "(none)" - : AuthentikKeypairs.FirstOrDefault(k => k.Pk == AuthentikSigningKeypairId) - ?? AuthentikKeypairs.First(); - - // Update slug fields to match selection - if (SelectedAuthorizationFlow != null) - AuthentikAuthorizationFlowSlug = SelectedAuthorizationFlow.Slug; - if (SelectedInvalidationFlow != null) - AuthentikInvalidationFlowSlug = SelectedInvalidationFlow.Slug; - if (SelectedSigningKeypair != null) - AuthentikSigningKeypairId = SelectedSigningKeypair.Pk; - } - - [RelayCommand] - private async Task TestXiboBootstrapAsync() - { - if (string.IsNullOrWhiteSpace(XiboBootstrapClientId) || string.IsNullOrWhiteSpace(XiboBootstrapClientSecret)) - { - StatusMessage = "Xibo Bootstrap Client ID and Secret are required."; - return; - } - - IsBusy = true; - StatusMessage = "Testing Xibo bootstrap credentials..."; - try - { - using var scope = _services.CreateScope(); - var xibo = scope.ServiceProvider.GetRequiredService(); - var svc = scope.ServiceProvider.GetRequiredService(); - var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com"); - // Use a placeholder URL — user must configure a live instance for full test - StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify."; - } - catch (Exception ex) - { - StatusMessage = $"Error: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - private async Task TestMySqlConnectionAsync() - { - if (string.IsNullOrWhiteSpace(MySqlHost) || string.IsNullOrWhiteSpace(MySqlAdminUser)) - { - StatusMessage = "MySQL Host and Admin User are required for connection test."; - return; - } - - IsBusy = true; - StatusMessage = "Testing MySQL connection..."; - try - { - if (!int.TryParse(MySqlPort, out var port)) - port = 3306; - - var docker = _services.GetRequiredService(); - var (connection, tunnel) = await docker.OpenMySqlConnectionAsync( - MySqlHost, port, MySqlAdminUser, MySqlAdminPassword); - await using var _ = connection; - using var __ = tunnel; - - await using var cmd = connection.CreateCommand(); - cmd.CommandText = "SELECT 1"; - await cmd.ExecuteScalarAsync(); - - StatusMessage = $"MySQL connection successful ({MySqlHost}:{port} via SSH tunnel)."; - } - catch (Exception ex) - { - StatusMessage = $"MySQL connection failed: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - /// - /// Persists Bitwarden bootstrap credentials to appsettings.json so they survive restarts. - /// - private async Task SaveBitwardenConfigToFileAsync() - { - var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); - var json = await File.ReadAllTextAsync(path); - var doc = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip })!; - - var bw = doc["Bitwarden"]?.AsObject(); - if (bw == null) - { - bw = new JsonObject(); - doc.AsObject()["Bitwarden"] = bw; - } - - bw["IdentityUrl"] = BitwardenIdentityUrl; - bw["ApiUrl"] = BitwardenApiUrl; - bw["AccessToken"] = BitwardenAccessToken; - bw["OrganizationId"] = BitwardenOrganizationId; - bw["ProjectId"] = BitwardenProjectId; - bw["InstanceProjectId"] = BitwardenInstanceProjectId; - - var options = new JsonSerializerOptions { WriteIndented = true }; - await File.WriteAllTextAsync(path, doc.ToJsonString(options)); - } - - private static string? NullIfEmpty(string? value) - => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); -} diff --git a/OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml b/OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml deleted file mode 100644 index 3590d0a..0000000 --- a/OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - -