Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs
Matt Batchelder adf1a2e4db
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Add WAL file for database and log instance deployment failures
2026-02-19 08:27:54 -05:00

146 lines
5.1 KiB
C#

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);
var (rmExit, _, rmErr) = await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}");
if (rmExit != 0)
{
_logger.LogError("Failed to remove old secret for rotation: {SecretName} | error={Error}", name, rmErr);
return (false, string.Empty);
}
}
// 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.");
}
}