feat: Add main application views and structure
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.
This commit is contained in:
Matt Batchelder
2026-02-18 10:43:27 -05:00
parent 29b8c23dbb
commit 45c94b6536
149 changed files with 6469 additions and 63498 deletions

View File

@@ -0,0 +1,186 @@
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Models.Entities;
using Renci.SshNet;
namespace OTSSignsOrchestrator.Desktop.Services;
/// <summary>
/// Manages SSH connections to remote Docker Swarm hosts.
/// Creates and caches SshClient instances with key or password authentication.
/// </summary>
public class SshConnectionService : IDisposable
{
private readonly ILogger<SshConnectionService> _logger;
private readonly Dictionary<Guid, SshClient> _clients = new();
private readonly object _lock = new();
public SshConnectionService(ILogger<SshConnectionService> logger)
{
_logger = logger;
}
/// <summary>
/// Get or create a connected SshClient for a given SshHost.
/// </summary>
public SshClient GetClient(SshHost host)
{
lock (_lock)
{
if (_clients.TryGetValue(host.Id, out var existing) && existing.IsConnected)
return existing;
// Dispose old client if disconnected
if (existing != null)
{
existing.Dispose();
_clients.Remove(host.Id);
}
var client = CreateClient(host);
client.Connect();
_clients[host.Id] = client;
_logger.LogInformation("SSH connected to {Host}:{Port} as {User}", host.Host, host.Port, host.Username);
return client;
}
}
/// <summary>
/// Test the SSH connection to a host. Returns (success, message).
/// </summary>
public async Task<(bool Success, string Message)> TestConnectionAsync(SshHost host)
{
return await Task.Run(() =>
{
try
{
using var client = CreateClient(host);
client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(10);
client.Connect();
if (client.IsConnected)
{
// Quick test: run a simple command
using var cmd = client.RunCommand("docker --version");
client.Disconnect();
if (cmd.ExitStatus == 0)
return (true, $"Connected. {cmd.Result.Trim()}");
else
return (true, $"Connected but docker not available: {cmd.Error}");
}
return (false, "Failed to connect.");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SSH connection test failed for {Host}:{Port}", host.Host, host.Port);
return (false, $"Connection failed: {ex.Message}");
}
});
}
/// <summary>
/// Run a command on the remote host and return (exitCode, stdout, stderr).
/// </summary>
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandAsync(SshHost host, string command)
{
return await Task.Run(() =>
{
var client = GetClient(host);
using var cmd = client.RunCommand(command);
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
});
}
/// <summary>
/// Run a command that requires stdin input (e.g., docker stack deploy --compose-file -).
/// </summary>
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandWithStdinAsync(
SshHost host, string command, string stdinContent)
{
return await Task.Run(() =>
{
var client = GetClient(host);
// Use shell stream approach for stdin piping
// We pipe via: echo '<content>' | <command>
// But for large YAML, use a heredoc approach
var safeContent = stdinContent.Replace("'", "'\\''");
var fullCommand = $"printf '%s' '{safeContent}' | {command}";
using var cmd = client.RunCommand(fullCommand);
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
});
}
/// <summary>
/// Disconnect and remove a cached client.
/// </summary>
public void Disconnect(Guid hostId)
{
lock (_lock)
{
if (_clients.TryGetValue(hostId, out var client))
{
client.Disconnect();
client.Dispose();
_clients.Remove(hostId);
_logger.LogInformation("SSH disconnected from host {HostId}", hostId);
}
}
}
private SshClient CreateClient(SshHost host)
{
var authMethods = new List<AuthenticationMethod>();
if (host.UseKeyAuth && !string.IsNullOrEmpty(host.PrivateKeyPath))
{
var keyFile = string.IsNullOrEmpty(host.KeyPassphrase)
? new PrivateKeyFile(host.PrivateKeyPath)
: new PrivateKeyFile(host.PrivateKeyPath, host.KeyPassphrase);
authMethods.Add(new PrivateKeyAuthenticationMethod(host.Username, keyFile));
}
if (!string.IsNullOrEmpty(host.Password))
{
authMethods.Add(new PasswordAuthenticationMethod(host.Username, host.Password));
}
if (authMethods.Count == 0)
{
// Fall back to default SSH agent / key in ~/.ssh/
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
{
authMethods.Add(new PrivateKeyAuthenticationMethod(host.Username, new PrivateKeyFile(defaultKeyPath)));
}
else
{
throw new InvalidOperationException(
$"No authentication method configured for SSH host '{host.Label}'. " +
"Provide a private key path or password.");
}
}
var connInfo = new ConnectionInfo(host.Host, host.Port, host.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
public void Dispose()
{
lock (_lock)
{
foreach (var client in _clients.Values)
{
try { client.Disconnect(); } catch { }
client.Dispose();
}
_clients.Clear();
}
}
}

View File

@@ -0,0 +1,159 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.Services;
/// <summary>
/// Docker CLI service that executes docker commands on a remote host over SSH.
/// Requires an SshHost to be set before use via SetHost().
/// </summary>
public class SshDockerCliService : IDockerCliService
{
private readonly SshConnectionService _ssh;
private readonly DockerOptions _options;
private readonly ILogger<SshDockerCliService> _logger;
private SshHost? _currentHost;
public SshDockerCliService(
SshConnectionService ssh,
IOptions<DockerOptions> options,
ILogger<SshDockerCliService> logger)
{
_ssh = ssh;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Set the SSH host to use for Docker commands.
/// </summary>
public void SetHost(SshHost host)
{
_currentHost = host;
}
public SshHost? CurrentHost => _currentHost;
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
{
EnsureHost();
var sw = Stopwatch.StartNew();
var args = "docker stack deploy --compose-file -";
if (resolveImage)
args += " --resolve-image changed";
args += $" {stackName}";
_logger.LogInformation("Deploying stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
var (exitCode, stdout, stderr) = await _ssh.RunCommandWithStdinAsync(_currentHost, args, composeYaml);
sw.Stop();
var result = new DeploymentResultDto
{
StackName = stackName,
Success = exitCode == 0,
ExitCode = exitCode,
Output = stdout,
ErrorMessage = stderr,
Message = exitCode == 0 ? "Success" : "Failed",
DurationMs = sw.ElapsedMilliseconds
};
if (result.Success)
_logger.LogInformation("Stack deployed via SSH: {StackName} | duration={DurationMs}ms", stackName, result.DurationMs);
else
_logger.LogError("Stack deploy failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
return result;
}
public async Task<DeploymentResultDto> RemoveStackAsync(string stackName)
{
EnsureHost();
var sw = Stopwatch.StartNew();
_logger.LogInformation("Removing stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost, $"docker stack rm {stackName}");
sw.Stop();
var result = new DeploymentResultDto
{
StackName = stackName,
Success = exitCode == 0,
ExitCode = exitCode,
Output = stdout,
ErrorMessage = stderr,
Message = exitCode == 0 ? "Success" : "Failed",
DurationMs = sw.ElapsedMilliseconds
};
if (result.Success)
_logger.LogInformation("Stack removed via SSH: {StackName}", stackName);
else
_logger.LogError("Stack remove failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
return result;
}
public async Task<List<StackInfo>> ListStacksAsync()
{
EnsureHost();
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
_currentHost!, "docker stack ls --format '{{.Name}}\\t{{.Services}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return new List<StackInfo>();
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 2);
return new StackInfo
{
Name = parts[0].Trim(),
ServiceCount = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var c) ? c : 0
};
})
.ToList();
}
public async Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName)
{
EnsureHost();
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
_currentHost!, $"docker stack services {stackName} --format '{{{{.Name}}}}\\t{{{{.Image}}}}\\t{{{{.Replicas}}}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return new List<ServiceInfo>();
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 3);
return new ServiceInfo
{
Name = parts.Length > 0 ? parts[0].Trim() : "",
Image = parts.Length > 1 ? parts[1].Trim() : "",
Replicas = parts.Length > 2 ? parts[2].Trim() : ""
};
})
.ToList();
}
private void EnsureHost()
{
if (_currentHost == null)
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
}
}

View File

@@ -0,0 +1,140 @@
using System.Globalization;
using System.Text;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.Services;
/// <summary>
/// Docker Swarm secrets management over SSH.
/// Uses docker CLI commands executed remotely instead of Docker.DotNet.
/// </summary>
public class SshDockerSecretsService : IDockerSecretsService
{
private readonly SshConnectionService _ssh;
private readonly ILogger<SshDockerSecretsService> _logger;
private SshHost? _currentHost;
public SshDockerSecretsService(SshConnectionService ssh, ILogger<SshDockerSecretsService> logger)
{
_ssh = ssh;
_logger = logger;
}
public void SetHost(SshHost host) => _currentHost = host;
public SshHost? CurrentHost => _currentHost;
public async Task<(bool Created, string SecretId)> EnsureSecretAsync(string name, string value, bool rotate = false)
{
EnsureHost();
_logger.LogInformation("Ensuring secret exists via SSH: {SecretName}", name);
// Check if secret already exists
var existing = await FindSecretAsync(name);
if (existing != null && !rotate)
{
_logger.LogDebug("Secret already exists: {SecretName} (id={SecretId})", name, existing.Value.id);
return (false, existing.Value.id);
}
if (existing != null && rotate)
{
_logger.LogInformation("Rotating secret via SSH: {SecretName} (old id={SecretId})", name, existing.Value.id);
await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}");
}
// Create secret via stdin
var safeValue = value.Replace("'", "'\\''");
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(
_currentHost!, $"printf '%s' '{safeValue}' | docker secret create {name} -");
if (exitCode != 0)
{
_logger.LogError("Failed to create secret via SSH: {SecretName} | error={Error}", name, stderr);
return (false, string.Empty);
}
var secretId = stdout.Trim();
_logger.LogInformation("Secret created via SSH: {SecretName} (id={SecretId})", name, secretId);
return (true, secretId);
}
public async Task<List<SecretListItem>> ListSecretsAsync()
{
EnsureHost();
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
_currentHost!, "docker secret ls --format '{{.ID}}\\t{{.Name}}\\t{{.CreatedAt}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return new List<SecretListItem>();
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 3);
return new SecretListItem
{
Id = parts.Length > 0 ? parts[0].Trim() : "",
Name = parts.Length > 1 ? parts[1].Trim() : "",
CreatedAt = parts.Length > 2 && DateTime.TryParse(parts[2].Trim(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)
? dt
: DateTime.MinValue
};
})
.ToList();
}
public async Task<bool> DeleteSecretAsync(string name)
{
EnsureHost();
var existing = await FindSecretAsync(name);
if (existing == null)
{
_logger.LogDebug("Secret not found for deletion: {SecretName}", name);
return true; // idempotent
}
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}");
if (exitCode != 0)
{
_logger.LogError("Failed to delete secret via SSH: {SecretName} | error={Error}", name, stderr);
return false;
}
_logger.LogInformation("Secret deleted via SSH: {SecretName}", name);
return true;
}
private async Task<(string id, string name)?> FindSecretAsync(string name)
{
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
_currentHost!, $"docker secret ls --filter 'name={name}' --format '{{{{.ID}}}}\\t{{{{.Name}}}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return null;
var line = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault(l =>
{
var parts = l.Split('\t', 2);
return parts.Length > 1 && string.Equals(parts[1].Trim(), name, StringComparison.OrdinalIgnoreCase);
});
if (line == null) return null;
var p = line.Split('\t', 2);
return (p[0].Trim(), p[1].Trim());
}
private void EnsureHost()
{
if (_currentHost == null)
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker secrets.");
}
}