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
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user