431 lines
19 KiB
C#
431 lines
19 KiB
C#
|
|
using Microsoft.AspNetCore.SignalR;
|
||
|
|
using Microsoft.EntityFrameworkCore;
|
||
|
|
using Renci.SshNet;
|
||
|
|
using OTSSignsOrchestrator.Core.Services;
|
||
|
|
using OTSSignsOrchestrator.Server.Clients;
|
||
|
|
using OTSSignsOrchestrator.Server.Data;
|
||
|
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||
|
|
using OTSSignsOrchestrator.Server.Hubs;
|
||
|
|
|
||
|
|
namespace OTSSignsOrchestrator.Server.Workers;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Full decommission pipeline — removes all infrastructure for a cancelled subscription.
|
||
|
|
/// Handles <c>JobType = "decommission"</c>.
|
||
|
|
///
|
||
|
|
/// Steps:
|
||
|
|
/// 1. stack-remove — docker stack rm xibo-{abbrev}
|
||
|
|
/// 2. authentik-cleanup — Delete SAML provider, application, 4 tenant groups (+BYOI source)
|
||
|
|
/// 3. oauth2-cleanup — Delete Xibo OAuth2 application via API
|
||
|
|
/// 4. mysql-cleanup — DROP DATABASE + DROP USER via SSH
|
||
|
|
/// 5. nfs-archive — mv /nfs/{abbrev} → /nfs/archived/{abbrev}-{timestamp} (retain 30d min)
|
||
|
|
/// 6. registry-update — Customer.Status = Decommissioned, Instance health = Critical,
|
||
|
|
/// final AuditLog, broadcast InstanceStatusChanged
|
||
|
|
/// </summary>
|
||
|
|
public sealed class DecommissionPipeline : IProvisioningPipeline
|
||
|
|
{
|
||
|
|
public string HandlesJobType => "decommission";
|
||
|
|
|
||
|
|
private const int TotalSteps = 6;
|
||
|
|
|
||
|
|
private readonly IServiceProvider _services;
|
||
|
|
private readonly ILogger<DecommissionPipeline> _logger;
|
||
|
|
|
||
|
|
public DecommissionPipeline(
|
||
|
|
IServiceProvider services,
|
||
|
|
ILogger<DecommissionPipeline> logger)
|
||
|
|
{
|
||
|
|
_services = services;
|
||
|
|
_logger = logger;
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task ExecuteAsync(Job job, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await using var scope = _services.CreateAsyncScope();
|
||
|
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||
|
|
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>();
|
||
|
|
var authentikClient = scope.ServiceProvider.GetRequiredService<IAuthentikClient>();
|
||
|
|
var xiboFactory = scope.ServiceProvider.GetRequiredService<XiboClientFactory>();
|
||
|
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||
|
|
|
||
|
|
var ctx = await BuildContextAsync(job, db, ct);
|
||
|
|
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
|
||
|
|
|
||
|
|
var abbrev = ctx.Abbreviation;
|
||
|
|
var stackName = ctx.DockerStackName;
|
||
|
|
|
||
|
|
// ── Step 1: stack-remove ────────────────────────────────────────────
|
||
|
|
await runner.RunAsync("stack-remove", async () =>
|
||
|
|
{
|
||
|
|
var sshHost = await GetSwarmSshHostAsync(settings);
|
||
|
|
using var sshClient = CreateSshClient(sshHost);
|
||
|
|
sshClient.Connect();
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var result = RunSshCommand(sshClient, $"docker stack rm {stackName}");
|
||
|
|
|
||
|
|
db.AuditLogs.Add(new AuditLog
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
InstanceId = ctx.InstanceId,
|
||
|
|
Actor = "system/decommission",
|
||
|
|
Action = "stack-remove",
|
||
|
|
Target = stackName,
|
||
|
|
Outcome = "success",
|
||
|
|
Detail = $"Docker stack '{stackName}' removed. Job {job.Id}.",
|
||
|
|
OccurredAt = DateTime.UtcNow,
|
||
|
|
});
|
||
|
|
await db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
return $"Docker stack '{stackName}' removed. Output: {result}";
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
sshClient.Disconnect();
|
||
|
|
}
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
// ── Step 2: authentik-cleanup ───────────────────────────────────────
|
||
|
|
await runner.RunAsync("authentik-cleanup", async () =>
|
||
|
|
{
|
||
|
|
var instance = await db.Instances
|
||
|
|
.Include(i => i.ByoiConfigs)
|
||
|
|
.FirstOrDefaultAsync(i => i.Id == ctx.InstanceId, ct)
|
||
|
|
?? throw new InvalidOperationException($"Instance {ctx.InstanceId} not found.");
|
||
|
|
|
||
|
|
var cleaned = new List<string>();
|
||
|
|
|
||
|
|
// Delete SAML provider (stored on Instance.AuthentikProviderId)
|
||
|
|
if (!string.IsNullOrEmpty(instance.AuthentikProviderId)
|
||
|
|
&& int.TryParse(instance.AuthentikProviderId, out var providerId))
|
||
|
|
{
|
||
|
|
await authentikClient.DeleteSamlProviderAsync(providerId);
|
||
|
|
cleaned.Add($"SAML provider {providerId}");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delete Authentik application
|
||
|
|
await authentikClient.DeleteApplicationAsync($"xibo-{abbrev}");
|
||
|
|
cleaned.Add($"application xibo-{abbrev}");
|
||
|
|
|
||
|
|
// Delete 4 tenant groups — search by name prefix, match by exact name
|
||
|
|
var groupNames = new[]
|
||
|
|
{
|
||
|
|
$"customer-{abbrev}",
|
||
|
|
$"customer-{abbrev}-viewer",
|
||
|
|
$"customer-{abbrev}-editor",
|
||
|
|
$"customer-{abbrev}-admin",
|
||
|
|
};
|
||
|
|
|
||
|
|
var searchResp = await authentikClient.ListGroupsAsync(search: $"customer-{abbrev}");
|
||
|
|
if (searchResp.IsSuccessStatusCode && searchResp.Content?.Results is { } groups)
|
||
|
|
{
|
||
|
|
foreach (var groupName in groupNames)
|
||
|
|
{
|
||
|
|
var match = groups.FirstOrDefault(g =>
|
||
|
|
g.TryGetValue("name", out var n) && n?.ToString() == groupName);
|
||
|
|
|
||
|
|
if (match is not null && match.TryGetValue("pk", out var pk))
|
||
|
|
{
|
||
|
|
await authentikClient.DeleteGroupAsync(pk.ToString()!);
|
||
|
|
cleaned.Add($"group {groupName}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// If BYOI was enabled, delete the SAML source
|
||
|
|
var byoiConfig = instance.ByoiConfigs.FirstOrDefault(b => b.Enabled);
|
||
|
|
if (byoiConfig is not null)
|
||
|
|
{
|
||
|
|
await authentikClient.DeleteSamlSourceAsync($"byoi-{abbrev}");
|
||
|
|
cleaned.Add($"BYOI SAML source byoi-{abbrev}");
|
||
|
|
}
|
||
|
|
|
||
|
|
db.AuditLogs.Add(new AuditLog
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
InstanceId = ctx.InstanceId,
|
||
|
|
Actor = "system/decommission",
|
||
|
|
Action = "authentik-cleanup",
|
||
|
|
Target = $"xibo-{abbrev}",
|
||
|
|
Outcome = "success",
|
||
|
|
Detail = $"Cleaned up: {string.Join(", ", cleaned)}. Job {job.Id}.",
|
||
|
|
OccurredAt = DateTime.UtcNow,
|
||
|
|
});
|
||
|
|
await db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
return $"Authentik cleanup completed: {string.Join(", ", cleaned)}.";
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
// ── Step 3: oauth2-cleanup ──────────────────────────────────────────
|
||
|
|
await runner.RunAsync("oauth2-cleanup", async () =>
|
||
|
|
{
|
||
|
|
var oauthReg = await db.OauthAppRegistries
|
||
|
|
.Where(r => r.InstanceId == ctx.InstanceId)
|
||
|
|
.OrderByDescending(r => r.CreatedAt)
|
||
|
|
.FirstOrDefaultAsync(ct);
|
||
|
|
|
||
|
|
if (oauthReg is null)
|
||
|
|
return "No OauthAppRegistry found — skipping OAuth2 cleanup.";
|
||
|
|
|
||
|
|
// Get Xibo client to delete the application
|
||
|
|
var oauthSecret = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
|
||
|
|
if (string.IsNullOrEmpty(oauthSecret))
|
||
|
|
return $"OAuth secret not found for '{abbrev}' — cannot authenticate to delete app. Manual cleanup required.";
|
||
|
|
|
||
|
|
var xiboClient = await xiboFactory.CreateAsync(ctx.InstanceUrl, oauthReg.ClientId, oauthSecret);
|
||
|
|
await xiboClient.DeleteApplicationAsync(oauthReg.ClientId);
|
||
|
|
|
||
|
|
db.AuditLogs.Add(new AuditLog
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
InstanceId = ctx.InstanceId,
|
||
|
|
Actor = "system/decommission",
|
||
|
|
Action = "oauth2-cleanup",
|
||
|
|
Target = oauthReg.ClientId,
|
||
|
|
Outcome = "success",
|
||
|
|
Detail = $"OAuth2 application '{oauthReg.ClientId}' deleted from Xibo. Job {job.Id}.",
|
||
|
|
OccurredAt = DateTime.UtcNow,
|
||
|
|
});
|
||
|
|
await db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
return $"OAuth2 application '{oauthReg.ClientId}' deleted.";
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
// ── Step 4: mysql-cleanup ───────────────────────────────────────────
|
||
|
|
await runner.RunAsync("mysql-cleanup", async () =>
|
||
|
|
{
|
||
|
|
var mysqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||
|
|
var mysqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||
|
|
var mysqlAdminUser = await settings.GetAsync(SettingsService.MySqlAdminUser, "root");
|
||
|
|
var mysqlAdminPassword = await settings.GetAsync(SettingsService.MySqlAdminPassword, string.Empty);
|
||
|
|
|
||
|
|
if (!int.TryParse(mysqlPort, out var port)) port = 3306;
|
||
|
|
|
||
|
|
var dbName = $"xibo_{abbrev}";
|
||
|
|
var userName = $"xibo_{abbrev}";
|
||
|
|
|
||
|
|
var sshHost = await GetSwarmSshHostAsync(settings);
|
||
|
|
using var sshClient = CreateSshClient(sshHost);
|
||
|
|
sshClient.Connect();
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// DROP DATABASE
|
||
|
|
var dropDbCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||
|
|
$"-p'{mysqlAdminPassword}' -e " +
|
||
|
|
$"\"DROP DATABASE IF EXISTS \\`{dbName}\\`\"";
|
||
|
|
RunSshCommand(sshClient, dropDbCmd);
|
||
|
|
|
||
|
|
// DROP USER
|
||
|
|
var dropUserCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||
|
|
$"-p'{mysqlAdminPassword}' -e " +
|
||
|
|
$"\"DROP USER IF EXISTS '{userName}'@'%'\"";
|
||
|
|
RunSshCommand(sshClient, dropUserCmd);
|
||
|
|
|
||
|
|
db.AuditLogs.Add(new AuditLog
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
InstanceId = ctx.InstanceId,
|
||
|
|
Actor = "system/decommission",
|
||
|
|
Action = "mysql-cleanup",
|
||
|
|
Target = dbName,
|
||
|
|
Outcome = "success",
|
||
|
|
Detail = $"Database '{dbName}' and user '{userName}' dropped. Job {job.Id}.",
|
||
|
|
OccurredAt = DateTime.UtcNow,
|
||
|
|
});
|
||
|
|
await db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
return $"Database '{dbName}' and user '{userName}' dropped on {mysqlHost}:{port}.";
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
sshClient.Disconnect();
|
||
|
|
}
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
// ── Step 5: nfs-archive ─────────────────────────────────────────────
|
||
|
|
await runner.RunAsync("nfs-archive", async () =>
|
||
|
|
{
|
||
|
|
var nfsServer = await settings.GetAsync(SettingsService.NfsServer);
|
||
|
|
var nfsExport = await settings.GetAsync(SettingsService.NfsExport);
|
||
|
|
var nfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder);
|
||
|
|
|
||
|
|
if (string.IsNullOrWhiteSpace(nfsServer))
|
||
|
|
return "NFS server not configured — skipping archive.";
|
||
|
|
|
||
|
|
var export = (nfsExport ?? string.Empty).TrimEnd('/');
|
||
|
|
var folder = (nfsExportFolder ?? string.Empty).Trim('/');
|
||
|
|
var basePath = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
|
||
|
|
|
||
|
|
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
|
||
|
|
var sourcePath = $"{basePath}/{abbrev}";
|
||
|
|
var archivePath = $"{basePath}/archived/{abbrev}-{timestamp}";
|
||
|
|
|
||
|
|
var sshHost = await GetSwarmSshHostAsync(settings);
|
||
|
|
using var sshClient = CreateSshClient(sshHost);
|
||
|
|
sshClient.Connect();
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// Temporarily mount NFS to move directories
|
||
|
|
var mountPoint = $"/tmp/nfs-decommission-{abbrev}";
|
||
|
|
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}");
|
||
|
|
RunSshCommand(sshClient, $"sudo mount -t nfs4 {nfsServer}:{basePath} {mountPoint}");
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// Ensure archive directory exists
|
||
|
|
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}/archived");
|
||
|
|
// Move — DO NOT delete (retain for 30 days minimum)
|
||
|
|
RunSshCommand(sshClient, $"sudo mv {mountPoint}/{abbrev} {mountPoint}/archived/{abbrev}-{timestamp}");
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}");
|
||
|
|
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}");
|
||
|
|
}
|
||
|
|
|
||
|
|
db.AuditLogs.Add(new AuditLog
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
InstanceId = ctx.InstanceId,
|
||
|
|
Actor = "system/decommission",
|
||
|
|
Action = "nfs-archive",
|
||
|
|
Target = sourcePath,
|
||
|
|
Outcome = "success",
|
||
|
|
Detail = $"NFS data archived to {archivePath}. Retained for minimum 30 days. Job {job.Id}.",
|
||
|
|
OccurredAt = DateTime.UtcNow,
|
||
|
|
});
|
||
|
|
await db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
return $"NFS data moved from {sourcePath} to {archivePath}. Retained for 30+ days.";
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
sshClient.Disconnect();
|
||
|
|
}
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
// ── Step 6: registry-update ─────────────────────────────────────────
|
||
|
|
await runner.RunAsync("registry-update", async () =>
|
||
|
|
{
|
||
|
|
var customer = await db.Customers.FirstOrDefaultAsync(c => c.Id == ctx.CustomerId, ct)
|
||
|
|
?? throw new InvalidOperationException($"Customer {ctx.CustomerId} not found.");
|
||
|
|
var instance = await db.Instances.FirstOrDefaultAsync(i => i.Id == ctx.InstanceId, ct)
|
||
|
|
?? throw new InvalidOperationException($"Instance {ctx.InstanceId} not found.");
|
||
|
|
|
||
|
|
customer.Status = CustomerStatus.Decommissioned;
|
||
|
|
instance.HealthStatus = HealthStatus.Critical;
|
||
|
|
|
||
|
|
db.AuditLogs.Add(new AuditLog
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
InstanceId = ctx.InstanceId,
|
||
|
|
Actor = "system/decommission",
|
||
|
|
Action = "decommission-complete",
|
||
|
|
Target = $"xibo-{abbrev}",
|
||
|
|
Outcome = "success",
|
||
|
|
Detail = $"Instance fully decommissioned. Customer status → Decommissioned. Job {job.Id}.",
|
||
|
|
OccurredAt = DateTime.UtcNow,
|
||
|
|
});
|
||
|
|
await db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
await hub.Clients.All.SendInstanceStatusChanged(
|
||
|
|
ctx.CustomerId.ToString(), CustomerStatus.Decommissioned.ToString());
|
||
|
|
|
||
|
|
return $"Customer '{abbrev}' → Decommissioned. Instance health → Critical. Broadcast sent.";
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
_logger.LogInformation("DecommissionPipeline completed for job {JobId} (abbrev={Abbrev})", job.Id, abbrev);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────
|
||
|
|
// Helpers (shared pattern from Phase1Pipeline)
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
private static async Task<PipelineContext> BuildContextAsync(Job job, OrchestratorDbContext db, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var customer = await db.Customers
|
||
|
|
.Include(c => c.Instances)
|
||
|
|
.FirstOrDefaultAsync(c => c.Id == job.CustomerId, ct)
|
||
|
|
?? throw new InvalidOperationException($"Customer {job.CustomerId} not found for job {job.Id}.");
|
||
|
|
|
||
|
|
var instance = customer.Instances.FirstOrDefault()
|
||
|
|
?? throw new InvalidOperationException($"No instance found for customer {job.CustomerId}.");
|
||
|
|
|
||
|
|
var abbrev = customer.Abbreviation.ToLowerInvariant();
|
||
|
|
|
||
|
|
return new PipelineContext
|
||
|
|
{
|
||
|
|
JobId = job.Id,
|
||
|
|
CustomerId = customer.Id,
|
||
|
|
InstanceId = instance.Id,
|
||
|
|
Abbreviation = abbrev,
|
||
|
|
CompanyName = customer.CompanyName,
|
||
|
|
AdminEmail = customer.AdminEmail,
|
||
|
|
AdminFirstName = customer.AdminFirstName,
|
||
|
|
InstanceUrl = instance.XiboUrl,
|
||
|
|
DockerStackName = instance.DockerStackName,
|
||
|
|
ParametersJson = job.Parameters,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(SettingsService settings)
|
||
|
|
{
|
||
|
|
var host = await settings.GetAsync("Ssh.SwarmHost")
|
||
|
|
?? throw new InvalidOperationException("SSH Swarm host not configured (Ssh.SwarmHost).");
|
||
|
|
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
|
||
|
|
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
|
||
|
|
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
|
||
|
|
var password = await settings.GetAsync("Ssh.SwarmPassword");
|
||
|
|
|
||
|
|
if (!int.TryParse(portStr, out var port)) port = 22;
|
||
|
|
|
||
|
|
return new SshConnectionInfo(host, port, user, keyPath, password);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static SshClient CreateSshClient(SshConnectionInfo info)
|
||
|
|
{
|
||
|
|
var authMethods = new List<AuthenticationMethod>();
|
||
|
|
|
||
|
|
if (!string.IsNullOrEmpty(info.KeyPath))
|
||
|
|
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
|
||
|
|
|
||
|
|
if (!string.IsNullOrEmpty(info.Password))
|
||
|
|
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
|
||
|
|
|
||
|
|
if (authMethods.Count == 0)
|
||
|
|
{
|
||
|
|
var defaultKeyPath = Path.Combine(
|
||
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
|
||
|
|
if (File.Exists(defaultKeyPath))
|
||
|
|
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
|
||
|
|
else
|
||
|
|
throw new InvalidOperationException(
|
||
|
|
$"No SSH authentication method available for {info.Host}:{info.Port}.");
|
||
|
|
}
|
||
|
|
|
||
|
|
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
|
||
|
|
return new SshClient(connInfo);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string RunSshCommand(SshClient client, string command)
|
||
|
|
{
|
||
|
|
using var cmd = client.RunCommand(command);
|
||
|
|
if (cmd.ExitStatus != 0)
|
||
|
|
throw new InvalidOperationException(
|
||
|
|
$"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
|
||
|
|
return cmd.Result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void RunSshCommandAllowFailure(SshClient client, string command)
|
||
|
|
{
|
||
|
|
using var cmd = client.RunCommand(command);
|
||
|
|
// Intentionally ignore exit code — used for idempotent cleanup operations
|
||
|
|
}
|
||
|
|
|
||
|
|
internal sealed record SshConnectionInfo(
|
||
|
|
string Host, int Port, string Username, string? KeyPath, string? Password);
|
||
|
|
}
|