Remove appsettings.json, rename CifsShareBasePath to CifsShareName, add CifsShareFolder to CmsInstances, and create a template.yml for Docker configuration.
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
Matt Batchelder
2026-02-18 16:15:54 -05:00
parent 45c94b6536
commit 4a903bfd2a
32 changed files with 1474 additions and 2289 deletions

View File

@@ -4,6 +4,7 @@ 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;
@@ -72,21 +73,33 @@ public class InstanceService
{
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
// ── Check uniqueness ────────────────────────────────────────────
// ── 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)
throw new InvalidOperationException($"Stack '{stackName}' already exists.");
{
_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 template repo (optional) ───────────────────────────
// ── 1. Clone / refresh template repo ───────────────────────────
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);
}
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);
@@ -116,7 +129,8 @@ public class InstanceService
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 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");
@@ -130,7 +144,7 @@ public class InstanceService
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// ── 4. Render compose YAML ──────────────────────────────────────
// ── 4. Render compose YAML from template ────────────────────────
var renderCtx = new RenderContext
{
CustomerName = dto.CustomerName,
@@ -158,20 +172,21 @@ public class InstanceService
PhpPostMaxSize = phpPostMaxSize,
PhpUploadMaxFilesize = phpUploadMaxFilesize,
PhpMaxExecutionTime = phpMaxExecutionTime,
IncludeNewt = true,
PangolinEndpoint = pangolinEndpoint,
NewtId = dto.NewtId,
NewtSecret = dto.NewtSecret,
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath,
CifsShareName = cifsShareName,
CifsShareFolder = cifsShareFolder,
CifsUsername = cifsUsername,
CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions,
SecretNames = new List<string> { mysqlSecretName },
};
var composeYaml = _compose.Render(renderCtx);
_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)
{
@@ -180,12 +195,35 @@ public class InstanceService
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
}
// ── 5. Deploy stack ─────────────────────────────────────────────
// ── 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}");
// ── 6. Record instance ──────────────────────────────────────────
// ── 8. Record instance ──────────────────────────────────────────
var instance = new CmsInstance
{
CustomerName = dto.CustomerName,
@@ -202,7 +240,8 @@ public class InstanceService
Status = InstanceStatus.Active,
SshHostId = dto.SshHostId,
CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath,
CifsShareName = cifsShareName,
CifsShareFolder = cifsShareFolder,
CifsUsername = cifsUsername,
CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions,
@@ -220,7 +259,7 @@ public class InstanceService
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
stackName, instance.Id, sw.ElapsedMilliseconds);
deployResult.ServiceCount = renderCtx.IncludeNewt ? 4 : 3;
deployResult.ServiceCount = 4;
deployResult.Message = "Instance deployed successfully.";
return deployResult;
}
@@ -238,13 +277,13 @@ public class InstanceService
}
/// <summary>
/// Creates MySQL database and user on external MySQL server via SSH.
/// Called by the ViewModel before CreateInstanceAsync since it needs SSH access.
/// 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.
/// </summary>
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
string abbrev,
string mysqlPassword,
Func<string, Task<(int ExitCode, string Stdout, string Stderr)>> runSshCommand)
string mysqlPassword)
{
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
@@ -254,29 +293,65 @@ public class InstanceService
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("'", "'\\''");
_logger.LogInformation("Creating MySQL database {Db} and user {User} via direct TCP", dbName, userName);
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;";
if (!int.TryParse(mySqlPort, out var port))
port = 3306;
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)
var csb = new MySqlConnectionStringBuilder
{
_logger.LogInformation("MySQL database and user created: {Db} / {User}", dbName, userName);
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.");
}
var error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
_logger.LogError("MySQL setup failed: {Error}", error);
return (false, $"MySQL setup failed: {error.Trim()}");
catch (MySqlException ex)
{
_logger.LogError(ex, "MySQL setup failed for database {Db}", dbName);
return (false, $"MySQL setup failed: {ex.Message}");
}
}
public async Task<DeploymentResultDto> UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null)
@@ -299,7 +374,8 @@ public class InstanceService
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.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;
@@ -324,12 +400,13 @@ public class InstanceService
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";
// 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");
@@ -340,6 +417,17 @@ public class InstanceService
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,
@@ -367,18 +455,19 @@ public class InstanceService
PhpPostMaxSize = phpPostMaxSize,
PhpUploadMaxFilesize = phpUploadMaxFilesize,
PhpMaxExecutionTime = phpMaxExecutionTime,
IncludeNewt = true,
PangolinEndpoint = pangolinEndpoint,
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath,
CifsShareName = cifsShareName,
CifsShareFolder = cifsShareFolder,
CifsUsername = cifsUsername,
CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions,
SecretNames = new List<string> { mysqlSecretName },
};
var composeYaml = _compose.Render(renderCtx);
_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)
{
@@ -387,6 +476,30 @@ public class InstanceService
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}");