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:
@@ -118,11 +118,11 @@ public class App : Application
|
||||
// SSH services (singletons — maintain connections)
|
||||
services.AddSingleton<SshConnectionService>();
|
||||
|
||||
// Docker services via SSH (scoped so they get fresh per-operation context)
|
||||
services.AddTransient<SshDockerCliService>();
|
||||
services.AddTransient<SshDockerSecretsService>();
|
||||
services.AddTransient<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
|
||||
services.AddTransient<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
|
||||
// Docker services via SSH (singletons — SetHost() must persist across scopes)
|
||||
services.AddSingleton<SshDockerCliService>();
|
||||
services.AddSingleton<SshDockerSecretsService>();
|
||||
services.AddSingleton<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
|
||||
services.AddSingleton<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
|
||||
|
||||
// Core services
|
||||
services.AddTransient<SettingsService>();
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
|
||||
<PackageReference Include="MySqlConnector" Version="2.5.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
|
||||
@@ -151,9 +151,145 @@ public class SshDockerCliService : IDockerCliService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureDirectoryAsync(string path)
|
||||
{
|
||||
EnsureHost();
|
||||
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"mkdir -p {path}");
|
||||
if (exitCode != 0)
|
||||
_logger.LogWarning("Failed to create directory {Path} on {Host}: {Error}", path, _currentHost!.Label, stderr);
|
||||
else
|
||||
_logger.LogInformation("Ensured directory exists on {Host}: {Path}", _currentHost!.Label, path);
|
||||
return exitCode == 0;
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureSmbFoldersAsync(
|
||||
string cifsServer,
|
||||
string cifsShareName,
|
||||
string cifsUsername,
|
||||
string cifsPassword,
|
||||
IEnumerable<string> folderNames,
|
||||
string? cifsShareFolder = null)
|
||||
{
|
||||
EnsureHost();
|
||||
var allSucceeded = true;
|
||||
var subFolder = (cifsShareFolder ?? string.Empty).Trim('/');
|
||||
|
||||
// If a subfolder is specified, ensure it exists first
|
||||
if (!string.IsNullOrEmpty(subFolder))
|
||||
{
|
||||
var mkdirCmd = $"smbclient //{cifsServer}/{cifsShareName} -U '{cifsUsername}%{cifsPassword}' -c 'mkdir {subFolder}' 2>&1";
|
||||
var (_, mkdirOut, _) = await _ssh.RunCommandAsync(_currentHost!, mkdirCmd);
|
||||
var mkdirOutput = mkdirOut ?? string.Empty;
|
||||
|
||||
var alreadyExists = mkdirOutput.Contains("NT_STATUS_OBJECT_NAME_COLLISION", StringComparison.OrdinalIgnoreCase)
|
||||
|| mkdirOutput.Contains("already exists", StringComparison.OrdinalIgnoreCase);
|
||||
var success = alreadyExists || !mkdirOutput.Contains("NT_STATUS_", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (success)
|
||||
_logger.LogInformation("SMB subfolder ensured: //{Server}/{Share}/{Folder}", cifsServer, cifsShareName, subFolder);
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to create SMB subfolder //{Server}/{Share}/{Folder}: {Output}",
|
||||
cifsServer, cifsShareName, subFolder, mkdirOutput.Trim());
|
||||
allSucceeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the target path prefix for volume folders
|
||||
var pathPrefix = string.IsNullOrEmpty(subFolder) ? string.Empty : $"{subFolder}/";
|
||||
|
||||
foreach (var folder in folderNames)
|
||||
{
|
||||
var targetFolder = $"{pathPrefix}{folder}";
|
||||
// Run smbclient on the remote Docker host to create the folder on the share.
|
||||
// NT_STATUS_OBJECT_NAME_COLLISION means it already exists — treat as success.
|
||||
var cmd = $"smbclient //{cifsServer}/{cifsShareName} -U '{cifsUsername}%{cifsPassword}' -c 'mkdir {targetFolder}' 2>&1";
|
||||
var (_, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd);
|
||||
var output = stdout ?? string.Empty;
|
||||
|
||||
var exists = output.Contains("NT_STATUS_OBJECT_NAME_COLLISION", StringComparison.OrdinalIgnoreCase)
|
||||
|| output.Contains("already exists", StringComparison.OrdinalIgnoreCase);
|
||||
var ok = exists || !output.Contains("NT_STATUS_", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (ok)
|
||||
_logger.LogInformation("SMB folder ensured: //{Server}/{Share}/{Folder}", cifsServer, cifsShareName, targetFolder);
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to create SMB folder //{Server}/{Share}/{Folder}: {Output}",
|
||||
cifsServer, cifsShareName, targetFolder, output.Trim());
|
||||
allSucceeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
return allSucceeded;
|
||||
}
|
||||
|
||||
private void EnsureHost()
|
||||
{
|
||||
if (_currentHost == null)
|
||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveStackVolumesAsync(string stackName)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
// ── 1. Remove the stack first so containers release the volumes ─────
|
||||
_logger.LogInformation("Removing stack {StackName} before volume cleanup", stackName);
|
||||
var (rmExit, _, rmErr) = await _ssh.RunCommandAsync(_currentHost!,
|
||||
$"docker stack rm {stackName} 2>&1 || true");
|
||||
if (rmExit != 0)
|
||||
_logger.LogWarning("Stack rm returned non-zero for {StackName}: {Err}", stackName, rmErr);
|
||||
|
||||
// Give Swarm a moment to tear down containers on all nodes
|
||||
await Task.Delay(5000);
|
||||
|
||||
// ── 2. Clean volumes on the local (manager) node ────────────────────
|
||||
var localCmd = $"docker volume ls --filter \"name={stackName}_\" -q | xargs -r docker volume rm 2>&1 || true";
|
||||
var (_, localOut, _) = await _ssh.RunCommandAsync(_currentHost!, localCmd);
|
||||
if (!string.IsNullOrEmpty(localOut?.Trim()))
|
||||
_logger.LogInformation("Volume cleanup (manager): {Output}", localOut!.Trim());
|
||||
|
||||
// ── 3. Clean volumes on ALL swarm nodes via a temporary global service ──
|
||||
// This deploys a short-lived container on every node that mounts the Docker
|
||||
// socket and removes matching volumes. This handles worker nodes that the
|
||||
// orchestrator has no direct SSH access to.
|
||||
var cleanupSvcName = $"vol-cleanup-{stackName}".Replace("_", "-");
|
||||
|
||||
// Remove leftover cleanup service from a previous run (if any)
|
||||
await _ssh.RunCommandAsync(_currentHost!,
|
||||
$"docker service rm {cleanupSvcName} 2>/dev/null || true");
|
||||
|
||||
var createCmd = string.Join(" ",
|
||||
"docker service create",
|
||||
"--detach",
|
||||
"--mode global",
|
||||
"--restart-condition none",
|
||||
$"--name {cleanupSvcName}",
|
||||
"--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock",
|
||||
"docker:cli",
|
||||
"sh", "-c",
|
||||
$"'docker volume ls -q --filter name={stackName}_ | xargs -r docker volume rm 2>&1; echo done'");
|
||||
|
||||
_logger.LogInformation("Deploying global volume-cleanup service on all swarm nodes for {StackName}", stackName);
|
||||
var (svcExit, svcOut, svcErr) = await _ssh.RunCommandAsync(_currentHost!, createCmd);
|
||||
|
||||
if (svcExit != 0)
|
||||
{
|
||||
_logger.LogWarning("Global volume cleanup service creation failed: {Err}", svcErr);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Wait for the cleanup tasks to finish on all nodes
|
||||
_logger.LogInformation("Waiting for volume cleanup tasks to complete on all nodes...");
|
||||
await Task.Delay(10000);
|
||||
}
|
||||
|
||||
// Remove the cleanup service
|
||||
await _ssh.RunCommandAsync(_currentHost!,
|
||||
$"docker service rm {cleanupSvcName} 2>/dev/null || true");
|
||||
|
||||
_logger.LogInformation("Volume cleanup complete for stack {StackName}", stackName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
|
||||
// CIFS / SMB credentials (per-instance, defaults loaded from global settings)
|
||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareName = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareFolder = string.Empty;
|
||||
[ObservableProperty] private string _cifsUsername = string.Empty;
|
||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
|
||||
@@ -110,7 +111,8 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
using var scope = _services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
|
||||
CifsShareBasePath = await settings.GetAsync(SettingsService.CifsShareBasePath) ?? string.Empty;
|
||||
CifsShareName = await settings.GetAsync(SettingsService.CifsShareName) ?? string.Empty;
|
||||
CifsShareFolder = await settings.GetAsync(SettingsService.CifsShareFolder) ?? string.Empty;
|
||||
CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty;
|
||||
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
|
||||
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
@@ -148,7 +150,6 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
dockerCli.SetHost(SelectedSshHost);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(SelectedSshHost);
|
||||
var ssh = _services.GetRequiredService<SshConnectionService>();
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
@@ -161,12 +162,11 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
SetProgress(20, "Generating secrets...");
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
|
||||
// ── Step 3: Create MySQL database + user via SSH ───────────────
|
||||
// ── Step 3: Create MySQL database + user via direct TCP ────────
|
||||
SetProgress(35, "Creating MySQL database and user...");
|
||||
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
|
||||
Abbrev,
|
||||
mysqlPassword,
|
||||
cmd => ssh.RunCommandAsync(SelectedSshHost, cmd));
|
||||
mysqlPassword);
|
||||
|
||||
AppendOutput($"[MySQL] {mysqlMsg}");
|
||||
if (!mysqlOk)
|
||||
@@ -195,7 +195,8 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
||||
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
||||
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
|
||||
CifsShareBasePath = string.IsNullOrWhiteSpace(CifsShareBasePath) ? null : CifsShareBasePath.Trim(),
|
||||
CifsShareName = string.IsNullOrWhiteSpace(CifsShareName) ? null : CifsShareName.Trim(),
|
||||
CifsShareFolder = string.IsNullOrWhiteSpace(CifsShareFolder) ? null : CifsShareFolder.Trim(),
|
||||
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
|
||||
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
|
||||
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MySqlConnector;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
@@ -42,7 +43,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
|
||||
// ── CIFS ────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareName = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareFolder = string.Empty;
|
||||
[ObservableProperty] private string _cifsUsername = string.Empty;
|
||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
|
||||
@@ -100,7 +102,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
|
||||
// CIFS
|
||||
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty);
|
||||
CifsShareBasePath = await svc.GetAsync(SettingsService.CifsShareBasePath, string.Empty);
|
||||
CifsShareName = await svc.GetAsync(SettingsService.CifsShareName, string.Empty);
|
||||
CifsShareFolder = await svc.GetAsync(SettingsService.CifsShareFolder, string.Empty);
|
||||
CifsUsername = await svc.GetAsync(SettingsService.CifsUsername, string.Empty);
|
||||
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
|
||||
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
@@ -166,7 +169,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
|
||||
// CIFS
|
||||
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsShareBasePath, NullIfEmpty(CifsShareBasePath), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsShareName, NullIfEmpty(CifsShareName), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsShareFolder, NullIfEmpty(CifsShareFolder), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
|
||||
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
|
||||
@@ -208,30 +212,34 @@ public partial class SettingsViewModel : ObservableObject
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Testing MySQL connection via SSH...";
|
||||
StatusMessage = "Testing MySQL connection...";
|
||||
try
|
||||
{
|
||||
// The test runs a mysql --version or a simple SELECT 1 query via SSH
|
||||
// We need an SshHost to route through — use the first available
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OTSSignsOrchestrator.Core.Data.XiboContext>();
|
||||
var host = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
|
||||
.FirstOrDefaultAsync(db.SshHosts);
|
||||
if (!int.TryParse(MySqlPort, out var port))
|
||||
port = 3306;
|
||||
|
||||
if (host == null)
|
||||
var csb = new MySqlConnectionStringBuilder
|
||||
{
|
||||
StatusMessage = "No SSH hosts configured. Add one in the Hosts page first.";
|
||||
return;
|
||||
}
|
||||
Server = MySqlHost,
|
||||
Port = (uint)port,
|
||||
UserID = MySqlAdminUser,
|
||||
Password = MySqlAdminPassword,
|
||||
ConnectionTimeout = 10,
|
||||
SslMode = MySqlSslMode.Preferred,
|
||||
};
|
||||
|
||||
var ssh = _services.GetRequiredService<Services.SshConnectionService>();
|
||||
var port = int.TryParse(MySqlPort, out var p) ? p : 3306;
|
||||
var cmd = $"mysql -h {MySqlHost} -P {port} -u {MySqlAdminUser} -p'{MySqlAdminPassword.Replace("'", "'\\''")}' -e 'SELECT 1' 2>&1";
|
||||
var (exitCode, stdout, stderr) = await ssh.RunCommandAsync(host, cmd);
|
||||
await using var connection = new MySqlConnection(csb.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
StatusMessage = exitCode == 0
|
||||
? $"MySQL connection successful via {host.Label}."
|
||||
: $"MySQL connection failed: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}";
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1";
|
||||
await cmd.ExecuteScalarAsync();
|
||||
|
||||
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port}).";
|
||||
}
|
||||
catch (MySqlException ex)
|
||||
{
|
||||
StatusMessage = $"MySQL connection failed: {ex.Message}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -54,8 +54,11 @@
|
||||
<TextBlock Text="CIFS Server" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" />
|
||||
|
||||
<TextBlock Text="Share Base Path" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareBasePath}" Watermark="e.g. /share/cms" />
|
||||
<TextBlock Text="Share Name" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareName}" Watermark="e.g. u548897-sub1" />
|
||||
|
||||
<TextBlock Text="Share Folder (optional)" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareFolder}" Watermark="e.g. ots_cms (leave empty for share root)" />
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsUsername}" Watermark="SMB username" />
|
||||
|
||||
@@ -127,8 +127,11 @@
|
||||
<TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsServer}" Watermark="nas.local" />
|
||||
|
||||
<TextBlock Text="Share Base Path" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareBasePath}" Watermark="/share/cms" />
|
||||
<TextBlock Text="Share Name" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareName}" Watermark="u548897-sub1" />
|
||||
|
||||
<TextBlock Text="Share Folder (optional)" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareFolder}" Watermark="ots_cms (leave empty for share root)" />
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsUsername}" Watermark="smbuser" />
|
||||
|
||||
Reference in New Issue
Block a user