Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
145 lines
5.1 KiB
C#
145 lines
5.1 KiB
C#
using System.Globalization;
|
|
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.");
|
|
}
|
|
}
|