- Added IInvitationSetupService and InvitationSetupService to orchestrate the setup of invitation infrastructure for customers. - Introduced methods for creating groups, enrollment flows, invitation stages, roles, and policies in Authentik. - Updated PostInstanceInitService to call the new invitation setup methods during post-initialization. - Enhanced InstanceService to pass customer name during SAML configuration deployment. - Updated App.axaml.cs to register the new IInvitationSetupService. - Modified settings-custom.php.template to include documentation for SAML authentication configuration with group-based admin assignment. - Added logic to exclude specific groups from being synced to Xibo during group synchronization.
768 lines
42 KiB
C#
768 lines
42 KiB
C#
using System.Diagnostics;
|
|
using System.Security.Cryptography;
|
|
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;
|
|
using static OTSSignsOrchestrator.Core.Configuration.AppConstants;
|
|
|
|
namespace OTSSignsOrchestrator.Core.Services;
|
|
|
|
/// <summary>
|
|
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete).
|
|
/// All instance data is live — nothing is persisted locally beyond the audit log.
|
|
/// 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, NFS 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 PostInstanceInitService _postInit;
|
|
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,
|
|
PostInstanceInitService postInit,
|
|
IOptions<DockerOptions> dockerOptions,
|
|
ILogger<InstanceService> logger)
|
|
{
|
|
_db = db;
|
|
_git = git;
|
|
_compose = compose;
|
|
_validation = validation;
|
|
_docker = docker;
|
|
_secrets = secrets;
|
|
_xibo = xibo;
|
|
_settings = settings;
|
|
_postInit = postInit;
|
|
_dockerOptions = dockerOptions.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Helper: derive the 3-letter abbreviation from a stack name like "acm-cms-stack"
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
private static string ExtractAbbrev(string stackName)
|
|
=> stackName.EndsWith("-cms-stack")
|
|
? stackName[..^"-cms-stack".Length]
|
|
: stackName.Split('-')[0];
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Create
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <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 abbrev = dto.CustomerAbbrev.Trim().ToLowerInvariant();
|
|
var stackName = $"{abbrev}-cms-stack";
|
|
var opLog = StartOperation(OperationType.Create, userId, stackName);
|
|
|
|
try
|
|
{
|
|
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
|
|
|
|
// ── 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);
|
|
|
|
// ── 1b. Remove stale stack (and optionally its cached volumes) ─────
|
|
// docker stack rm alone leaves named volumes behind; those volumes
|
|
// retain their old driver_opts and Docker re-uses them on the next
|
|
// deploy, ignoring the new (correct) options in the compose file.
|
|
// PurgeStaleVolumes must be explicitly opted into to avoid accidental data loss.
|
|
if (dto.PurgeStaleVolumes)
|
|
{
|
|
_logger.LogInformation("Purging stale stack and volumes (PurgeStaleVolumes=true): {StackName}", stackName);
|
|
await _docker.RemoveStackVolumesAsync(stackName);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("Removing stale stack (if any): {StackName}", stackName);
|
|
await _docker.RemoveStackAsync(stackName);
|
|
await Task.Delay(2000);
|
|
}
|
|
|
|
// ── 2. Generate MySQL credentials ──────────────────────────────
|
|
var mysqlPassword = GenerateRandomPassword(32);
|
|
var mySqlUserName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
|
|
|
|
var userSecretName = CustomerMysqlUserSecretName(abbrev);
|
|
|
|
var (userOk, _) = await _secrets.EnsureSecretAsync(userSecretName, mySqlUserName, rotate: true);
|
|
if (!userOk)
|
|
throw new InvalidOperationException($"Failed to create/rotate Docker secret '{userSecretName}'.");
|
|
_logger.LogInformation("Docker secret created/rotated: {SecretName}", userSecretName);
|
|
|
|
// Global secrets: MySQL host + port (idempotent, shared across instances)
|
|
var mySqlHostValue = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
|
var mySqlPortValue = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
|
|
await _secrets.EnsureSecretAsync(GlobalMysqlHostSecretName, mySqlHostValue);
|
|
await _secrets.EnsureSecretAsync(GlobalMysqlPortSecretName, mySqlPortValue);
|
|
_logger.LogInformation("Global MySQL secrets ensured: {Host}, {Port}", GlobalMysqlHostSecretName, GlobalMysqlPortSecretName);
|
|
|
|
// ── 2b. Create MySQL database + user ─────────────────────────────
|
|
_logger.LogInformation("Creating MySQL database and user for instance {Abbrev}", abbrev);
|
|
var (mysqlOk, mysqlMsg) = await CreateMySqlDatabaseAsync(abbrev, mysqlPassword);
|
|
if (!mysqlOk)
|
|
throw new InvalidOperationException($"MySQL database/user setup failed: {mysqlMsg}");
|
|
_logger.LogInformation("MySQL setup complete: {Message}", mysqlMsg);
|
|
|
|
// ── 2c. Persist password for future redeploys ────────
|
|
await _settings.SetAsync(
|
|
SettingsService.InstanceMySqlPassword(abbrev), mysqlPassword,
|
|
SettingsService.CatInstance, isSensitive: true);
|
|
_logger.LogInformation("MySQL password stored in Bitwarden for instance {Abbrev}", abbrev);
|
|
|
|
// ── 3. Read settings ────────────────────────────────────────────
|
|
var mySqlHost = mySqlHostValue;
|
|
var mySqlPort = mySqlPortValue;
|
|
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
|
var mySqlUser = mySqlUserName;
|
|
|
|
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 nfsServer = dto.NfsServer ?? await _settings.GetAsync(SettingsService.NfsServer);
|
|
var nfsExport = dto.NfsExport ?? await _settings.GetAsync(SettingsService.NfsExport);
|
|
var nfsExportFolder = dto.NfsExportFolder ?? await _settings.GetAsync(SettingsService.NfsExportFolder);
|
|
var nfsOptions = dto.NfsExtraOptions ?? await _settings.GetAsync(SettingsService.NfsOptions, string.Empty);
|
|
|
|
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,
|
|
MySqlPassword = mysqlPassword,
|
|
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,
|
|
NfsServer = nfsServer,
|
|
NfsExport = nfsExport,
|
|
NfsExportFolder = nfsExportFolder,
|
|
NfsExtraOptions = nfsOptions,
|
|
};
|
|
|
|
_logger.LogInformation("NFS render values: server={NfsServer}, export={NfsExport}, folder={NfsExportFolder}",
|
|
nfsServer, nfsExport, nfsExportFolder);
|
|
|
|
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 NFS export folders exist ─────────────────────────
|
|
if (!string.IsNullOrWhiteSpace(nfsServer) && !string.IsNullOrWhiteSpace(nfsExport))
|
|
{
|
|
var nfsFolders = ComposeRenderService.ExtractNfsDeviceFolders(composeYaml, nfsExport, nfsExportFolder);
|
|
_logger.LogInformation("Ensuring NFS export folders exist on {Server}:{Export} — {Folders}",
|
|
nfsServer, nfsExport, string.Join(", ", nfsFolders));
|
|
var (nfsFoldersOk, nfsError) = await _docker.EnsureNfsFoldersWithErrorAsync(nfsServer, nfsExport, nfsFolders, nfsExportFolder);
|
|
if (!nfsFoldersOk)
|
|
throw new InvalidOperationException(
|
|
$"Failed to create NFS volume directories on {nfsServer}:{nfsExport}: {nfsError}\n"
|
|
+ "Common causes: (1) SSH user needs passwordless sudo (NOPASSWD) for mount/umount/mkdir, "
|
|
+ "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server.");
|
|
}
|
|
|
|
// ── 5c. Write settings-custom.php to NFS volume (SAML config) ────────
|
|
// This must happen before the stack is deployed so Xibo starts with SAML
|
|
// authentication already configured.
|
|
var instanceUrlForSaml = $"https://{cmsServerName}/{abbrev}";
|
|
await _postInit.DeploySamlConfigurationAsync(abbrev, instanceUrlForSaml, _settings, dto.CustomerName);
|
|
|
|
// ── 6. Deploy stack ─────────────────────────────────────────────
|
|
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
|
if (!deployResult.Success)
|
|
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
|
|
|
mysqlPassword = string.Empty;
|
|
|
|
sw.Stop();
|
|
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} | duration={DurationMs}ms", stackName, sw.ElapsedMilliseconds);
|
|
|
|
// ── 7. Return result — post-init will be triggered by the UI ──────
|
|
// after the user creates an OAuth2 app in the Xibo web UI and supplies
|
|
// the client_id and client_secret.
|
|
var instanceUrl = $"https://{cmsServerName}/{abbrev}";
|
|
|
|
deployResult.InstanceUrl = instanceUrl;
|
|
deployResult.Abbrev = abbrev;
|
|
deployResult.ServiceCount = 4;
|
|
deployResult.Message = "Instance deployed successfully. Complete post-install setup by providing OAuth credentials.";
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Update / redeploy
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Redeploys an existing stack. All render parameters come from <paramref name="dto"/>
|
|
/// (populated from live service inspection or user input), falling back to global settings
|
|
/// where values are null.
|
|
/// </summary>
|
|
public async Task<DeploymentResultDto> UpdateInstanceAsync(string stackName, UpdateInstanceDto dto, string? userId = null)
|
|
{
|
|
var sw = Stopwatch.StartNew();
|
|
var opLog = StartOperation(OperationType.Update, userId, stackName);
|
|
|
|
try
|
|
{
|
|
var abbrev = dto.CustomerAbbrev?.Trim().ToLowerInvariant() ?? ExtractAbbrev(stackName);
|
|
var customerName = dto.CustomerName ?? abbrev;
|
|
|
|
_logger.LogInformation("Updating instance: {StackName} (abbrev={Abbrev})", stackName, abbrev);
|
|
|
|
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);
|
|
|
|
// Retrieve the stored MySQL password (encrypted in settings since creation)
|
|
var mySqlPassword = await _settings.GetAsync(SettingsService.InstanceMySqlPassword(abbrev));
|
|
if (string.IsNullOrEmpty(mySqlPassword))
|
|
throw new InvalidOperationException(
|
|
$"No stored MySQL password found for instance '{abbrev}'. " +
|
|
"The instance may have been created before password persistence was added. " +
|
|
"Use 'Rotate Password' to generate and store a new one.");
|
|
|
|
// Ensure MySQL Docker secrets exist (idempotent)
|
|
var userSecretName = CustomerMysqlUserSecretName(abbrev);
|
|
await _secrets.EnsureSecretAsync(userSecretName, mySqlUser);
|
|
await _secrets.EnsureSecretAsync(GlobalMysqlHostSecretName, mySqlHost);
|
|
await _secrets.EnsureSecretAsync(GlobalMysqlPortSecretName, mySqlPort);
|
|
|
|
var smtpServer = dto.SmtpServer ?? await _settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
|
var smtpUsername = dto.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 cmsServerName = dto.CmsServerName
|
|
?? (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
|
|
var hostHttpPort = dto.HostHttpPort ?? 80;
|
|
var themePath = dto.ThemeHostPath
|
|
?? (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
|
|
|
var nfsServer = dto.NfsServer ?? await _settings.GetAsync(SettingsService.NfsServer);
|
|
var nfsExport = dto.NfsExport ?? await _settings.GetAsync(SettingsService.NfsExport);
|
|
var nfsExportFolder = dto.NfsExportFolder ?? await _settings.GetAsync(SettingsService.NfsExportFolder);
|
|
var nfsOptions = dto.NfsExtraOptions ?? await _settings.GetAsync(SettingsService.NfsOptions, string.Empty);
|
|
|
|
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 repoUrl = dto.TemplateRepoUrl ?? await _settings.GetAsync(SettingsService.GitRepoUrl);
|
|
var repoPat = dto.TemplateRepoPat ?? 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.");
|
|
|
|
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
|
|
|
|
var renderCtx = new RenderContext
|
|
{
|
|
CustomerName = customerName,
|
|
CustomerAbbrev = abbrev,
|
|
StackName = stackName,
|
|
CmsServerName = cmsServerName,
|
|
HostHttpPort = hostHttpPort,
|
|
CmsImage = cmsImage,
|
|
MemcachedImage = memcachedImage,
|
|
QuickChartImage = quickChartImage,
|
|
NewtImage = newtImage,
|
|
ThemeHostPath = themePath,
|
|
MySqlHost = mySqlHost,
|
|
MySqlPort = mySqlPort,
|
|
MySqlDatabase = mySqlDbName,
|
|
MySqlUser = mySqlUser,
|
|
MySqlPassword = mySqlPassword,
|
|
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,
|
|
NfsServer = nfsServer,
|
|
NfsExport = nfsExport,
|
|
NfsExportFolder = nfsExportFolder,
|
|
NfsExtraOptions = nfsOptions,
|
|
};
|
|
|
|
_logger.LogInformation("NFS render values (update): server={NfsServer}, export={NfsExport}, folder={NfsExportFolder}",
|
|
nfsServer, nfsExport, nfsExportFolder);
|
|
|
|
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)}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(themePath))
|
|
await _docker.EnsureDirectoryAsync(themePath);
|
|
|
|
if (!string.IsNullOrWhiteSpace(nfsServer) && !string.IsNullOrWhiteSpace(nfsExport))
|
|
{
|
|
var nfsFolders = ComposeRenderService.ExtractNfsDeviceFolders(composeYaml, nfsExport, nfsExportFolder);
|
|
_logger.LogInformation("Ensuring NFS export folders on {Server}:{Export} — {Folders}",
|
|
nfsServer, nfsExport, string.Join(", ", nfsFolders));
|
|
var (nfsFoldersOk, nfsError) = await _docker.EnsureNfsFoldersWithErrorAsync(nfsServer, nfsExport, nfsFolders, nfsExportFolder);
|
|
if (!nfsFoldersOk)
|
|
throw new InvalidOperationException(
|
|
$"Failed to create NFS volume directories on {nfsServer}:{nfsExport}: {nfsError}\n"
|
|
+ "Common causes: (1) SSH user needs passwordless sudo (NOPASSWD) for mount/umount/mkdir, "
|
|
+ "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server.");
|
|
}
|
|
|
|
_logger.LogInformation("Removing stale NFS volumes for stack {StackName}", stackName);
|
|
await _docker.RemoveStackVolumesAsync(stackName);
|
|
|
|
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml, resolveImage: true);
|
|
if (!deployResult.Success)
|
|
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");
|
|
|
|
sw.Stop();
|
|
opLog.Status = OperationStatus.Success;
|
|
opLog.Message = $"Instance updated: {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: {StackName}", stackName);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Delete
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public async Task<DeploymentResultDto> DeleteInstanceAsync(
|
|
string stackName,
|
|
string customerAbbrev,
|
|
bool retainSecrets = false,
|
|
bool clearXiboCreds = true,
|
|
string? userId = null)
|
|
{
|
|
var sw = Stopwatch.StartNew();
|
|
var opLog = StartOperation(OperationType.Delete, userId, stackName);
|
|
|
|
try
|
|
{
|
|
var abbrev = customerAbbrev.Trim().ToLowerInvariant();
|
|
|
|
_logger.LogInformation("Deleting instance: {StackName} (abbrev={Abbrev}) retainSecrets={RetainSecrets}",
|
|
stackName, abbrev, retainSecrets);
|
|
|
|
var result = await _docker.RemoveStackAsync(stackName);
|
|
|
|
if (!retainSecrets)
|
|
{
|
|
_logger.LogInformation("Dropping MySQL database and user for instance {StackName}", stackName);
|
|
var (dropOk, dropMsg) = await DropMySqlDatabaseAsync(abbrev);
|
|
if (!dropOk)
|
|
_logger.LogWarning("MySQL cleanup incomplete for {StackName}: {Message}", stackName, dropMsg);
|
|
else
|
|
_logger.LogInformation("MySQL cleanup complete: {Message}", dropMsg);
|
|
|
|
foreach (var secretName in AllCustomerMysqlSecretNames(abbrev))
|
|
await _secrets.DeleteSecretAsync(secretName);
|
|
|
|
// Remove the stored password from local settings
|
|
await _settings.SetAsync(
|
|
SettingsService.InstanceMySqlPassword(abbrev), null,
|
|
SettingsService.CatInstance, isSensitive: false);
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
sw.Stop();
|
|
opLog.Status = OperationStatus.Success;
|
|
opLog.Message = $"Instance deleted: {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: {StackName}", stackName);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// MySQL password rotation
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Rotates the MySQL password for an instance: generates a new password, updates the
|
|
/// Docker Swarm secret, updates the MySQL user via ALTER USER, then redeploys the stack.
|
|
/// </summary>
|
|
public async Task<(bool Success, string Message)> RotateMySqlPasswordAsync(string stackName, string? userId = null)
|
|
{
|
|
var abbrev = ExtractAbbrev(stackName);
|
|
var newPassword = GenerateRandomPassword(32);
|
|
|
|
_logger.LogInformation("Rotating MySQL password for instance {StackName}", stackName);
|
|
|
|
// ── Step 1: Update MySQL password via SSH tunnel ─────────────────────
|
|
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 mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
|
|
|
|
if (!int.TryParse(mySqlPort, out var port)) port = 3306;
|
|
|
|
var (mysqlOk, mysqlErr) = await _docker.AlterMySqlUserPasswordAsync(
|
|
mySqlHost, port, mySqlAdminUser, mySqlAdminPassword, mySqlUser, newPassword);
|
|
|
|
if (!mysqlOk)
|
|
return (false, $"MySQL ALTER USER failed (no service disruption): {mysqlErr}");
|
|
|
|
_logger.LogInformation("MySQL password updated for user {User}", mySqlUser);
|
|
|
|
// ── Step 2: Persist new password in settings (encrypted) ─────────────
|
|
await _settings.SetAsync(
|
|
SettingsService.InstanceMySqlPassword(abbrev), newPassword,
|
|
SettingsService.CatInstance, isSensitive: true);
|
|
await _db.SaveChangesAsync();
|
|
_logger.LogInformation("New MySQL password stored in settings for instance {Abbrev}", abbrev);
|
|
|
|
// ── Step 3: Force-update the web service to pick up new compose env ──
|
|
// Redeploy the stack so the MYSQL_PASSWORD env var gets the new value.
|
|
var webService = $"{stackName}_{abbrev}-web";
|
|
if (!await _docker.ForceUpdateServiceAsync(webService))
|
|
{
|
|
_logger.LogWarning(
|
|
"Force-update of service {Service} failed. MySQL has the new password but " +
|
|
"the container is still running with the old value. A manual redeploy may be needed.",
|
|
webService);
|
|
return (true, $"MySQL password rotated for '{stackName}' but service force-update failed. Redeploy to apply.");
|
|
}
|
|
|
|
_logger.LogInformation("Service {Service} force-updated with new password", webService);
|
|
return (true, $"MySQL password rotated successfully for '{stackName}'.");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// MySQL database helpers
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Creates MySQL database and user on the external MySQL server via SSH tunnel.
|
|
/// </summary>
|
|
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_user")).Replace("{abbrev}", abbrev);
|
|
|
|
_logger.LogInformation("Creating MySQL database {Db} and user {User} via SSH tunnel", dbName, userName);
|
|
|
|
if (!int.TryParse(mySqlPort, out var port))
|
|
port = 3306;
|
|
|
|
try
|
|
{
|
|
var (connection, tunnel) = await _docker.OpenMySqlConnectionAsync(
|
|
mySqlHost, port, mySqlAdminUser, mySqlAdminPassword);
|
|
await using var _ = connection;
|
|
using var __ = tunnel;
|
|
|
|
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 = $"ALTER USER '{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();
|
|
}
|
|
|
|
// Verify the new user's credentials by opening a connection as that user
|
|
try
|
|
{
|
|
var parsed = new MySqlConnectionStringBuilder(connection.ConnectionString);
|
|
var localPort = (int)parsed.Port;
|
|
|
|
var testCsb = new MySqlConnectionStringBuilder
|
|
{
|
|
Server = "127.0.0.1",
|
|
Port = (uint)localPort,
|
|
UserID = userName,
|
|
Password = mysqlPassword,
|
|
ConnectionTimeout = 5,
|
|
SslMode = MySqlSslMode.Disabled,
|
|
};
|
|
|
|
await using var testConn = new MySqlConnection(testCsb.ConnectionString);
|
|
await testConn.OpenAsync();
|
|
await testConn.CloseAsync();
|
|
}
|
|
catch (MySqlException ex)
|
|
{
|
|
_logger.LogError(ex, "Verification login failed for new MySQL user {User}", userName);
|
|
return (false, $"Verification login failed for user '{userName}': {ex.Message}");
|
|
}
|
|
|
|
_logger.LogInformation("MySQL database {Db} and user {User} created and verified 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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drops the MySQL database and user for a given instance abbreviation.
|
|
/// </summary>
|
|
public async Task<(bool Success, string Message)> DropMySqlDatabaseAsync(string abbrev)
|
|
{
|
|
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_user")).Replace("{abbrev}", abbrev);
|
|
|
|
_logger.LogInformation("Dropping MySQL database {Db} and user {User} via SSH tunnel", dbName, userName);
|
|
|
|
if (!int.TryParse(mySqlPort, out var port))
|
|
port = 3306;
|
|
|
|
try
|
|
{
|
|
var (connection, tunnel) = await _docker.OpenMySqlConnectionAsync(
|
|
mySqlHost, port, mySqlAdminUser, mySqlAdminPassword);
|
|
await using var _ = connection;
|
|
using var __ = tunnel;
|
|
|
|
var escapedDb = dbName.Replace("`", "``");
|
|
var escapedUser = userName.Replace("'", "''");
|
|
|
|
await using (var cmd = connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = $"DROP DATABASE IF EXISTS `{escapedDb}`";
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
await using (var cmd = connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = $"DROP USER IF EXISTS '{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} dropped successfully", dbName, userName);
|
|
return (true, $"Database '{dbName}' and user '{userName}' dropped.");
|
|
}
|
|
catch (MySqlException ex)
|
|
{
|
|
_logger.LogError(ex, "MySQL drop failed for database {Db}", dbName);
|
|
return (false, $"MySQL drop failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Private helpers
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private static OperationLog StartOperation(OperationType type, string? userId, string? stackName = null)
|
|
=> new OperationLog { Operation = type, UserId = userId, Status = OperationStatus.Pending, StackName = stackName };
|
|
|
|
private static string GenerateRandomPassword(int length)
|
|
{
|
|
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
return RandomNumberGenerator.GetString(chars, length);
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
|