Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
- Implemented CreateInstanceView for creating new instances. - Added HostsView for managing SSH hosts with CRUD operations. - Created InstancesView for displaying and managing instances. - Developed LogsView for viewing operation logs. - Introduced SecretsView for managing secrets associated with hosts. - Established SettingsView for configuring application settings. - Created MainWindow as the main application window with navigation. - Added app manifest and configuration files for logging and settings.
537 lines
26 KiB
C#
537 lines
26 KiB
C#
using System.Diagnostics;
|
||
using System.Security.Cryptography;
|
||
using System.Text.Json;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Options;
|
||
using OTSSignsOrchestrator.Core.Configuration;
|
||
using OTSSignsOrchestrator.Core.Data;
|
||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||
|
||
namespace OTSSignsOrchestrator.Core.Services;
|
||
|
||
/// <summary>
|
||
/// 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
|
||
/// </summary>
|
||
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<InstanceService> _logger;
|
||
|
||
public InstanceService(
|
||
XiboContext db,
|
||
GitTemplateService git,
|
||
ComposeRenderService compose,
|
||
ComposeValidationService validation,
|
||
IDockerCliService docker,
|
||
IDockerSecretsService secrets,
|
||
XiboApiService xibo,
|
||
SettingsService settings,
|
||
IOptions<DockerOptions> dockerOptions,
|
||
ILogger<InstanceService> logger)
|
||
{
|
||
_db = db;
|
||
_git = git;
|
||
_compose = compose;
|
||
_validation = validation;
|
||
_docker = docker;
|
||
_secrets = secrets;
|
||
_xibo = xibo;
|
||
_settings = settings;
|
||
_dockerOptions = dockerOptions.Value;
|
||
_logger = logger;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Creates a new CMS instance:
|
||
/// 1. Clone repo 2. Generate secrets 3. Create MySQL DB/user 4. Render compose 5. Deploy
|
||
/// </summary>
|
||
public async Task<DeploymentResultDto> 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 ────────────────────────────────────────────
|
||
var existing = await _db.CmsInstances.IgnoreQueryFilters()
|
||
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
|
||
if (existing != null)
|
||
throw new InvalidOperationException($"Stack '{stackName}' already exists.");
|
||
|
||
// ── 1. Clone template repo (optional) ───────────────────────────
|
||
var repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
|
||
var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat);
|
||
|
||
if (!string.IsNullOrWhiteSpace(repoUrl))
|
||
{
|
||
_logger.LogInformation("Cloning template repo: {RepoUrl}", repoUrl);
|
||
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 cifsShareBasePath = dto.CifsShareBasePath ?? await _settings.GetAsync(SettingsService.CifsShareBasePath);
|
||
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 ──────────────────────────────────────
|
||
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,
|
||
IncludeNewt = true,
|
||
PangolinEndpoint = pangolinEndpoint,
|
||
NewtId = dto.NewtId,
|
||
NewtSecret = dto.NewtSecret,
|
||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
||
CifsServer = cifsServer,
|
||
CifsShareBasePath = cifsShareBasePath,
|
||
CifsUsername = cifsUsername,
|
||
CifsPassword = cifsPassword,
|
||
CifsExtraOptions = cifsOptions,
|
||
SecretNames = new List<string> { mysqlSecretName },
|
||
};
|
||
|
||
var composeYaml = _compose.Render(renderCtx);
|
||
|
||
if (_dockerOptions.ValidateBeforeDeploy)
|
||
{
|
||
var validationResult = _validation.Validate(composeYaml, abbrev);
|
||
if (!validationResult.IsValid)
|
||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||
}
|
||
|
||
// ── 5. Deploy stack ─────────────────────────────────────────────
|
||
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||
if (!deployResult.Success)
|
||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
||
|
||
// ── 6. 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,
|
||
CifsShareBasePath = cifsShareBasePath,
|
||
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 = renderCtx.IncludeNewt ? 4 : 3;
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Creates MySQL database and user on external MySQL server via SSH.
|
||
/// Called by the ViewModel before CreateInstanceAsync since it needs SSH access.
|
||
/// </summary>
|
||
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
|
||
string abbrev,
|
||
string mysqlPassword,
|
||
Func<string, Task<(int ExitCode, string Stdout, string Stderr)>> runSshCommand)
|
||
{
|
||
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);
|
||
|
||
var safePwd = mySqlAdminPassword.Replace("'", "'\\''");
|
||
var safeUserPwd = mysqlPassword.Replace("'", "'\\''");
|
||
|
||
var sql = $"CREATE DATABASE IF NOT EXISTS \\`{dbName}\\`; "
|
||
+ $"CREATE USER IF NOT EXISTS '{userName}'@'%' IDENTIFIED BY '{safeUserPwd}'; "
|
||
+ $"GRANT ALL PRIVILEGES ON \\`{dbName}\\`.* TO '{userName}'@'%'; "
|
||
+ $"FLUSH PRIVILEGES;";
|
||
|
||
var cmd = $"mysql -h {mySqlHost} -P {mySqlPort} -u {mySqlAdminUser} -p'{safePwd}' -e \"{sql}\" 2>&1";
|
||
|
||
_logger.LogInformation("Creating MySQL database {Db} and user {User}", dbName, userName);
|
||
|
||
var (exitCode, stdout, stderr) = await runSshCommand(cmd);
|
||
|
||
if (exitCode == 0)
|
||
{
|
||
_logger.LogInformation("MySQL database and user created: {Db} / {User}", dbName, userName);
|
||
return (true, $"Database '{dbName}' and user '{userName}' created.");
|
||
}
|
||
|
||
var error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
|
||
_logger.LogError("MySQL setup failed: {Error}", error);
|
||
return (false, $"MySQL setup failed: {error.Trim()}");
|
||
}
|
||
|
||
public async Task<DeploymentResultDto> 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.CifsShareBasePath != null) instance.CifsShareBasePath = dto.CifsShareBasePath;
|
||
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
|
||
var cifsServer = instance.CifsServer;
|
||
var cifsShareBasePath = instance.CifsShareBasePath;
|
||
var cifsUsername = instance.CifsUsername;
|
||
var cifsPassword = instance.CifsPassword;
|
||
var cifsOptions = instance.CifsExtraOptions ?? "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");
|
||
|
||
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,
|
||
IncludeNewt = true,
|
||
PangolinEndpoint = pangolinEndpoint,
|
||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
||
CifsServer = cifsServer,
|
||
CifsShareBasePath = cifsShareBasePath,
|
||
CifsUsername = cifsUsername,
|
||
CifsPassword = cifsPassword,
|
||
CifsExtraOptions = cifsOptions,
|
||
SecretNames = new List<string> { mysqlSecretName },
|
||
};
|
||
|
||
var composeYaml = _compose.Render(renderCtx);
|
||
|
||
if (_dockerOptions.ValidateBeforeDeploy)
|
||
{
|
||
var validationResult = _validation.Validate(composeYaml, abbrev);
|
||
if (!validationResult.IsValid)
|
||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||
}
|
||
|
||
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<DeploymentResultDto> 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<CmsInstance?> GetInstanceAsync(Guid id)
|
||
=> await _db.CmsInstances.Include(i => i.SshHost).FirstOrDefaultAsync(i => i.Id == id);
|
||
|
||
public async Task<(List<CmsInstance> 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<XiboTestResult> 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);
|
||
}
|
||
}
|