using System.Diagnostics; using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MySqlConnector; using OTSSignsOrchestrator.Core.Configuration; using OTSSignsOrchestrator.Core.Data; using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.Entities; namespace OTSSignsOrchestrator.Core.Services; /// /// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect). /// New‐instance flow: /// 1. Clone template repo to local cache /// 2. Generate MySQL password → create Docker Swarm secret (never persisted locally) /// 3. Create MySQL database + user on external MySQL server via SSH /// 4. Render combined compose YAML (no MySQL container, CIFS volumes, Newt service) /// 5. Deploy stack via SSH /// public class InstanceService { private readonly XiboContext _db; private readonly GitTemplateService _git; private readonly ComposeRenderService _compose; private readonly ComposeValidationService _validation; private readonly IDockerCliService _docker; private readonly IDockerSecretsService _secrets; private readonly XiboApiService _xibo; private readonly SettingsService _settings; private readonly DockerOptions _dockerOptions; private readonly ILogger _logger; public InstanceService( XiboContext db, GitTemplateService git, ComposeRenderService compose, ComposeValidationService validation, IDockerCliService docker, IDockerSecretsService secrets, XiboApiService xibo, SettingsService settings, IOptions dockerOptions, ILogger logger) { _db = db; _git = git; _compose = compose; _validation = validation; _docker = docker; _secrets = secrets; _xibo = xibo; _settings = settings; _dockerOptions = dockerOptions.Value; _logger = logger; } /// /// Creates a new CMS instance: /// 1. Clone repo 2. Generate secrets 3. Create MySQL DB/user 4. Render compose 5. Deploy /// public async Task CreateInstanceAsync(CreateInstanceDto dto, string? userId = null) { var sw = Stopwatch.StartNew(); var opLog = StartOperation(OperationType.Create, userId); var abbrev = dto.CustomerAbbrev.Trim().ToLowerInvariant(); var stackName = $"{abbrev}-cms-stack"; try { _logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName); // ── Check uniqueness — redirect to update if already present ─── var existing = await _db.CmsInstances.IgnoreQueryFilters() .FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null); if (existing != null) { _logger.LogInformation("Instance '{StackName}' already exists in DB — applying stack update instead.", stackName); var updateDto = new UpdateInstanceDto { CifsServer = dto.CifsServer, CifsShareName = dto.CifsShareName, CifsShareFolder = dto.CifsShareFolder, CifsUsername = dto.CifsUsername, CifsPassword = dto.CifsPassword, CifsExtraOptions = dto.CifsExtraOptions, }; return await UpdateInstanceAsync(existing.Id, updateDto, userId); } // ── 1. Clone / refresh template repo ──────────────────────────── var repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl); var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat); if (string.IsNullOrWhiteSpace(repoUrl)) throw new InvalidOperationException("Git template repository URL is not configured. Set it in Settings → Git Repo URL."); _logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl); var templateConfig = await _git.FetchAsync(repoUrl, repoPat); // ── 2. Generate MySQL password → Docker Swarm secret ──────────── var mysqlPassword = GenerateRandomPassword(32); var mysqlSecretName = $"{abbrev}-cms-db-password"; await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword); await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName); _logger.LogInformation("Docker secret created: {SecretName}", mysqlSecretName); // ── 3. Read settings ──────────────────────────────────────────── 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")).Replace("{abbrev}", abbrev); var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.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 cifsServer = dto.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer); var cifsShareName = dto.CifsShareName ?? await _settings.GetAsync(SettingsService.CifsShareName); var cifsShareFolder = dto.CifsShareFolder ?? await _settings.GetAsync(SettingsService.CifsShareFolder); var cifsUsername = dto.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername); var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword); var cifsOptions = dto.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777"); 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"); // ── 4. Render compose YAML from template ──────────────────────── var renderCtx = new RenderContext { CustomerName = dto.CustomerName, 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 = dto.NewtId, NewtSecret = dto.NewtSecret, CifsServer = cifsServer, CifsShareName = cifsShareName, CifsShareFolder = cifsShareFolder, CifsUsername = cifsUsername, CifsPassword = cifsPassword, CifsExtraOptions = cifsOptions, }; _logger.LogInformation("CIFS render values: server={CifsServer}, share={CifsShareName}, folder={CifsShareFolder}, user={CifsUsername}", cifsServer, cifsShareName, cifsShareFolder, cifsUsername); var composeYaml = _compose.Render(templateConfig.Yaml, renderCtx); if (_dockerOptions.ValidateBeforeDeploy) { var validationResult = _validation.Validate(composeYaml, abbrev); if (!validationResult.IsValid) throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}"); } // ── 5. Ensure bind-mount directories exist on the remote host ─── if (!string.IsNullOrWhiteSpace(themePath)) await _docker.EnsureDirectoryAsync(themePath); // ── 5b. Ensure SMB share folders exist ─────────────────────────── if (!string.IsNullOrWhiteSpace(cifsServer) && !string.IsNullOrWhiteSpace(cifsShareName)) { var smbFolders = new[] { $"{abbrev}-cms-custom", $"{abbrev}-cms-backup", $"{abbrev}-cms-library", $"{abbrev}-cms-userscripts", $"{abbrev}-cms-ca-certs", }; _logger.LogInformation("Ensuring SMB share folders exist on //{Server}/{Share}", cifsServer, cifsShareName); await _docker.EnsureSmbFoldersAsync(cifsServer, cifsShareName, cifsUsername ?? string.Empty, cifsPassword ?? string.Empty, smbFolders, cifsShareFolder); } // ── 6. Remove stale CIFS volumes so Docker recreates them with current settings ─ _logger.LogInformation("Removing stale CIFS volumes for stack {StackName} to ensure fresh mount options", stackName); await _docker.RemoveStackVolumesAsync(stackName); // ── 7. Deploy stack ───────────────────────────────────────────── var deployResult = await _docker.DeployStackAsync(stackName, composeYaml); if (!deployResult.Success) throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}"); // ── 8. Record instance ────────────────────────────────────────── var instance = new CmsInstance { CustomerName = dto.CustomerName, CustomerAbbrev = abbrev, StackName = stackName, CmsServerName = cmsServerName, HostHttpPort = 80, ThemeHostPath = themePath, LibraryHostPath = $"{abbrev}-cms-library", SmtpServer = smtpServer, SmtpUsername = smtpUsername, TemplateRepoUrl = repoUrl ?? string.Empty, TemplateRepoPat = repoPat, Status = InstanceStatus.Active, SshHostId = dto.SshHostId, CifsServer = cifsServer, CifsShareName = cifsShareName, CifsShareFolder = cifsShareFolder, CifsUsername = cifsUsername, CifsPassword = cifsPassword, CifsExtraOptions = cifsOptions, }; _db.CmsInstances.Add(instance); sw.Stop(); opLog.InstanceId = instance.Id; opLog.Status = OperationStatus.Success; opLog.Message = $"Instance deployed: {stackName}"; opLog.DurationMs = sw.ElapsedMilliseconds; _db.OperationLogs.Add(opLog); await _db.SaveChangesAsync(); _logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms", stackName, instance.Id, sw.ElapsedMilliseconds); deployResult.ServiceCount = 4; deployResult.Message = "Instance deployed successfully."; return deployResult; } catch (Exception ex) { sw.Stop(); opLog.Status = OperationStatus.Failure; opLog.Message = $"Create failed: {ex.Message}"; opLog.DurationMs = sw.ElapsedMilliseconds; _db.OperationLogs.Add(opLog); await _db.SaveChangesAsync(); _logger.LogError(ex, "Instance create failed: {StackName}", stackName); throw; } } /// /// Creates MySQL database and user on the external MySQL server using a direct TCP connection. /// Admin credentials are sourced from SettingsService (MySql.AdminUser / MySql.AdminPassword). /// The new user's password is passed in and never logged. /// public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync( string abbrev, string mysqlPassword) { var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); var mySqlAdminUser = await _settings.GetAsync(SettingsService.MySqlAdminUser, "root"); var mySqlAdminPassword = await _settings.GetAsync(SettingsService.MySqlAdminPassword, string.Empty); var dbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); var userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev); _logger.LogInformation("Creating MySQL database {Db} and user {User} via direct TCP", dbName, userName); if (!int.TryParse(mySqlPort, out var port)) port = 3306; var csb = new MySqlConnectionStringBuilder { Server = mySqlHost, Port = (uint)port, UserID = mySqlAdminUser, Password = mySqlAdminPassword, ConnectionTimeout = 15, SslMode = MySqlSslMode.Preferred, }; try { await using var connection = new MySqlConnection(csb.ConnectionString); await connection.OpenAsync(); // Backtick-escape database name and single-quote-escape username to handle // any special characters in names. The new user password is passed as a // parameter so it is never interpolated into SQL text. var escapedDb = dbName.Replace("`", "``"); var escapedUser = userName.Replace("'", "''"); await using (var cmd = connection.CreateCommand()) { cmd.CommandText = $"CREATE DATABASE IF NOT EXISTS `{escapedDb}`"; await cmd.ExecuteNonQueryAsync(); } await using (var cmd = connection.CreateCommand()) { cmd.CommandText = $"CREATE USER IF NOT EXISTS '{escapedUser}'@'%' IDENTIFIED BY @pwd"; cmd.Parameters.AddWithValue("@pwd", mysqlPassword); await cmd.ExecuteNonQueryAsync(); } await using (var cmd = connection.CreateCommand()) { cmd.CommandText = $"GRANT ALL PRIVILEGES ON `{escapedDb}`.* TO '{escapedUser}'@'%'"; await cmd.ExecuteNonQueryAsync(); } await using (var cmd = connection.CreateCommand()) { cmd.CommandText = "FLUSH PRIVILEGES"; await cmd.ExecuteNonQueryAsync(); } _logger.LogInformation("MySQL database {Db} and user {User} created successfully", dbName, userName); return (true, $"Database '{dbName}' and user '{userName}' created."); } catch (MySqlException ex) { _logger.LogError(ex, "MySQL setup failed for database {Db}", dbName); return (false, $"MySQL setup failed: {ex.Message}"); } } public async Task UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null) { var sw = Stopwatch.StartNew(); var opLog = StartOperation(OperationType.Update, userId); try { var instance = await _db.CmsInstances.FindAsync(id) ?? throw new KeyNotFoundException($"Instance {id} not found."); _logger.LogInformation("Updating instance: {StackName} (id={Id})", instance.StackName, id); if (dto.TemplateRepoUrl != null) instance.TemplateRepoUrl = dto.TemplateRepoUrl; if (dto.TemplateRepoPat != null) instance.TemplateRepoPat = dto.TemplateRepoPat; if (dto.SmtpServer != null) instance.SmtpServer = dto.SmtpServer; if (dto.SmtpUsername != null) instance.SmtpUsername = dto.SmtpUsername; if (dto.Constraints != null) instance.Constraints = JsonSerializer.Serialize(dto.Constraints); if (dto.XiboUsername != null) instance.XiboUsername = dto.XiboUsername; if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword; if (dto.CifsServer != null) instance.CifsServer = dto.CifsServer; if (dto.CifsShareName != null) instance.CifsShareName = dto.CifsShareName; if (dto.CifsShareFolder != null) instance.CifsShareFolder = dto.CifsShareFolder; if (dto.CifsUsername != null) instance.CifsUsername = dto.CifsUsername; if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword; if (dto.CifsExtraOptions != null) instance.CifsExtraOptions = dto.CifsExtraOptions; var abbrev = instance.CustomerAbbrev; var mysqlSecretName = $"{abbrev}-cms-db-password"; // Read current settings for re-render 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")).Replace("{abbrev}", abbrev); var smtpServer = instance.SmtpServer; var smtpUsername = instance.SmtpUsername; 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"); // Use per-instance CIFS credentials, falling back to global settings var cifsServer = instance.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer); var cifsShareName = instance.CifsShareName ?? await _settings.GetAsync(SettingsService.CifsShareName); var cifsShareFolder = instance.CifsShareFolder ?? await _settings.GetAsync(SettingsService.CifsShareFolder); var cifsUsername = instance.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername); var cifsPassword = instance.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword); var cifsOptions = instance.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777"); 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"); // ── Fetch template from git ───────────────────────────────────── var repoUrl = instance.TemplateRepoUrl; var repoPat = instance.TemplateRepoPat ?? await _settings.GetAsync(SettingsService.GitRepoPat); if (string.IsNullOrWhiteSpace(repoUrl)) repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl); if (string.IsNullOrWhiteSpace(repoUrl)) throw new InvalidOperationException("Git template repository URL is not configured. Set it in Settings → Git Repo URL."); var templateConfig = await _git.FetchAsync(repoUrl, repoPat); var renderCtx = new RenderContext { CustomerName = instance.CustomerName, CustomerAbbrev = abbrev, StackName = instance.StackName, CmsServerName = instance.CmsServerName, HostHttpPort = instance.HostHttpPort, CmsImage = cmsImage, MemcachedImage = memcachedImage, QuickChartImage = quickChartImage, NewtImage = newtImage, ThemeHostPath = instance.ThemeHostPath, 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, CifsServer = cifsServer, CifsShareName = cifsShareName, CifsShareFolder = cifsShareFolder, CifsUsername = cifsUsername, CifsPassword = cifsPassword, CifsExtraOptions = cifsOptions, }; _logger.LogInformation("CIFS render values (update): server={CifsServer}, share={CifsShareName}, folder={CifsShareFolder}, user={CifsUsername}", cifsServer, cifsShareName, cifsShareFolder, cifsUsername); var composeYaml = _compose.Render(templateConfig.Yaml, renderCtx); if (_dockerOptions.ValidateBeforeDeploy) { var validationResult = _validation.Validate(composeYaml, abbrev); if (!validationResult.IsValid) throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}"); } // Ensure bind-mount directories exist on the remote host if (!string.IsNullOrWhiteSpace(instance.ThemeHostPath)) await _docker.EnsureDirectoryAsync(instance.ThemeHostPath); // Ensure SMB share folders exist if (!string.IsNullOrWhiteSpace(cifsServer) && !string.IsNullOrWhiteSpace(cifsShareName)) { var abbrevLower = instance.CustomerAbbrev; var smbFolders = new[] { $"{abbrevLower}-cms-custom", $"{abbrevLower}-cms-backup", $"{abbrevLower}-cms-library", $"{abbrevLower}-cms-userscripts", $"{abbrevLower}-cms-ca-certs", }; _logger.LogInformation("Ensuring SMB share folders exist on //{Server}/{Share}", cifsServer, cifsShareName); await _docker.EnsureSmbFoldersAsync(cifsServer, cifsShareName, cifsUsername ?? string.Empty, cifsPassword ?? string.Empty, smbFolders, cifsShareFolder); } // Remove stale CIFS volumes so Docker recreates them with current settings _logger.LogInformation("Removing stale CIFS volumes for stack {StackName} to ensure fresh mount options", instance.StackName); await _docker.RemoveStackVolumesAsync(instance.StackName); var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true); if (!deployResult.Success) throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}"); instance.UpdatedAt = DateTime.UtcNow; instance.Status = InstanceStatus.Active; sw.Stop(); opLog.InstanceId = instance.Id; opLog.Status = OperationStatus.Success; opLog.Message = $"Instance updated: {instance.StackName}"; opLog.DurationMs = sw.ElapsedMilliseconds; _db.OperationLogs.Add(opLog); await _db.SaveChangesAsync(); deployResult.ServiceCount = 4; deployResult.Message = "Instance updated and redeployed."; return deployResult; } catch (Exception ex) { sw.Stop(); opLog.Status = OperationStatus.Failure; opLog.Message = $"Update failed: {ex.Message}"; opLog.DurationMs = sw.ElapsedMilliseconds; _db.OperationLogs.Add(opLog); await _db.SaveChangesAsync(); _logger.LogError(ex, "Instance update failed (id={Id})", id); throw; } } public async Task DeleteInstanceAsync( Guid id, bool retainSecrets = false, bool clearXiboCreds = true, string? userId = null) { var sw = Stopwatch.StartNew(); var opLog = StartOperation(OperationType.Delete, userId); try { var instance = await _db.CmsInstances.FindAsync(id) ?? throw new KeyNotFoundException($"Instance {id} not found."); _logger.LogInformation("Deleting instance: {StackName} (id={Id}) retainSecrets={RetainSecrets}", instance.StackName, id, retainSecrets); var result = await _docker.RemoveStackAsync(instance.StackName); if (!retainSecrets) { var mysqlSecretName = $"{instance.CustomerAbbrev}-cms-db-password"; await _secrets.DeleteSecretAsync(mysqlSecretName); var secretMeta = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == mysqlSecretName); if (secretMeta != null) _db.SecretMetadata.Remove(secretMeta); } instance.Status = InstanceStatus.Deleted; instance.DeletedAt = DateTime.UtcNow; instance.UpdatedAt = DateTime.UtcNow; if (clearXiboCreds) { instance.XiboUsername = null; instance.XiboPassword = null; instance.XiboApiTestStatus = XiboApiTestStatus.Unknown; } sw.Stop(); opLog.InstanceId = instance.Id; opLog.Status = OperationStatus.Success; opLog.Message = $"Instance deleted: {instance.StackName}"; opLog.DurationMs = sw.ElapsedMilliseconds; _db.OperationLogs.Add(opLog); await _db.SaveChangesAsync(); result.Message = "Instance deleted."; return result; } catch (Exception ex) { sw.Stop(); opLog.Status = OperationStatus.Failure; opLog.Message = $"Delete failed: {ex.Message}"; opLog.DurationMs = sw.ElapsedMilliseconds; _db.OperationLogs.Add(opLog); await _db.SaveChangesAsync(); _logger.LogError(ex, "Instance delete failed (id={Id})", id); throw; } } public async Task GetInstanceAsync(Guid id) => await _db.CmsInstances.Include(i => i.SshHost).FirstOrDefaultAsync(i => i.Id == id); public async Task<(List Items, int TotalCount)> ListInstancesAsync( int page = 1, int pageSize = 50, string? filter = null) { var query = _db.CmsInstances.Include(i => i.SshHost).AsQueryable(); if (!string.IsNullOrWhiteSpace(filter)) query = query.Where(i => i.CustomerName.Contains(filter) || i.StackName.Contains(filter)); var total = await query.CountAsync(); var items = await query.OrderByDescending(i => i.CreatedAt) .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); return (items, total); } public async Task TestXiboConnectionAsync(Guid id) { var instance = await _db.CmsInstances.FindAsync(id) ?? throw new KeyNotFoundException($"Instance {id} not found."); if (string.IsNullOrEmpty(instance.XiboUsername) || string.IsNullOrEmpty(instance.XiboPassword)) return new XiboTestResult { IsValid = false, Message = "No Xibo credentials stored." }; var url = $"http://localhost:{instance.HostHttpPort}"; var result = await _xibo.TestConnectionAsync(url, instance.XiboUsername, instance.XiboPassword); instance.XiboApiTestStatus = result.IsValid ? XiboApiTestStatus.Success : XiboApiTestStatus.Failed; instance.XiboApiTestedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); return result; } private async Task EnsureSecretMetadata(string name, bool isGlobal, string? customerName) { var existing = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == name); if (existing == null) { _db.SecretMetadata.Add(new SecretMetadata { Name = name, IsGlobal = isGlobal, CustomerName = customerName }); } } private static OperationLog StartOperation(OperationType type, string? userId) => new OperationLog { Operation = type, UserId = userId, Status = OperationStatus.Pending }; private static string GenerateRandomPassword(int length) { const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"; return RandomNumberGenerator.GetString(chars, length); } }