495 lines
23 KiB
C#
495 lines
23 KiB
C#
|
|
using System.Security.Cryptography;
|
||
|
|
using System.Text.Json;
|
||
|
|
using Microsoft.AspNetCore.SignalR;
|
||
|
|
using Microsoft.EntityFrameworkCore;
|
||
|
|
using Renci.SshNet;
|
||
|
|
using OTSSignsOrchestrator.Core.Configuration;
|
||
|
|
using OTSSignsOrchestrator.Core.Services;
|
||
|
|
using OTSSignsOrchestrator.Server.Clients;
|
||
|
|
using OTSSignsOrchestrator.Server.Data;
|
||
|
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||
|
|
using OTSSignsOrchestrator.Server.Hubs;
|
||
|
|
using static OTSSignsOrchestrator.Core.Configuration.AppConstants;
|
||
|
|
|
||
|
|
namespace OTSSignsOrchestrator.Server.Workers;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Phase 1 provisioning pipeline — infrastructure setup. Handles <c>JobType = "provision"</c>.
|
||
|
|
///
|
||
|
|
/// Steps:
|
||
|
|
/// 1. mysql-setup — Create DB + user on external MySQL via SSH tunnel
|
||
|
|
/// 2. docker-secrets — Create Docker Swarm secrets via SSH
|
||
|
|
/// 3. nfs-dirs — Create NFS sub-directories via SSH
|
||
|
|
/// 4. authentik-provision — SAML provider, application, groups, invitation flow in Authentik
|
||
|
|
/// 5. stack-deploy — Render compose YAML and deploy via <c>docker stack deploy</c>
|
||
|
|
/// 6. credential-store — Store generated credentials in Bitwarden Secrets Manager
|
||
|
|
/// </summary>
|
||
|
|
public sealed class Phase1Pipeline : IProvisioningPipeline
|
||
|
|
{
|
||
|
|
public string HandlesJobType => "provision";
|
||
|
|
|
||
|
|
private const int TotalSteps = 6;
|
||
|
|
|
||
|
|
private readonly IServiceProvider _services;
|
||
|
|
private readonly ILogger<Phase1Pipeline> _logger;
|
||
|
|
|
||
|
|
public Phase1Pipeline(
|
||
|
|
IServiceProvider services,
|
||
|
|
ILogger<Phase1Pipeline> 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 composeRenderer = scope.ServiceProvider.GetRequiredService<ComposeRenderService>();
|
||
|
|
var gitService = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
|
||
|
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||
|
|
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;
|
||
|
|
|
||
|
|
// Mutable state accumulated across steps
|
||
|
|
var mysqlPassword = GenerateRandomPassword(32);
|
||
|
|
var mysqlDbName = $"xibo_{abbrev}";
|
||
|
|
var mysqlUserName = $"xibo_{abbrev}";
|
||
|
|
string? authentikProviderId = null;
|
||
|
|
|
||
|
|
// ── Step 1: mysql-setup ─────────────────────────────────────────────
|
||
|
|
await runner.RunAsync("mysql-setup", 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;
|
||
|
|
|
||
|
|
// SSH to MySQL host, create database and user
|
||
|
|
// Reuse pattern from InstanceService.CreateMySqlDatabaseAsync — runs SQL
|
||
|
|
// via SSH tunnel to the MySQL server.
|
||
|
|
var sshHost = await GetSwarmSshHostAsync(settings);
|
||
|
|
using var sshClient = CreateSshClient(sshHost);
|
||
|
|
sshClient.Connect();
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// Create database
|
||
|
|
var createDbCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||
|
|
$"-p'{mysqlAdminPassword}' -e " +
|
||
|
|
$"\"CREATE DATABASE IF NOT EXISTS \\`{mysqlDbName}\\`\"";
|
||
|
|
RunSshCommand(sshClient, createDbCmd);
|
||
|
|
|
||
|
|
// Create user
|
||
|
|
var createUserCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||
|
|
$"-p'{mysqlAdminPassword}' -e " +
|
||
|
|
$"\"CREATE USER IF NOT EXISTS '{mysqlUserName}'@'%' IDENTIFIED BY '{mysqlPassword}'\"";
|
||
|
|
RunSshCommand(sshClient, createUserCmd);
|
||
|
|
|
||
|
|
// Grant privileges
|
||
|
|
var grantCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||
|
|
$"-p'{mysqlAdminPassword}' -e " +
|
||
|
|
$"\"GRANT ALL PRIVILEGES ON \\`{mysqlDbName}\\`.* TO '{mysqlUserName}'@'%'; FLUSH PRIVILEGES;\"";
|
||
|
|
RunSshCommand(sshClient, grantCmd);
|
||
|
|
|
||
|
|
return $"Database '{mysqlDbName}' and user '{mysqlUserName}' created on {mysqlHost}:{port}.";
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
sshClient.Disconnect();
|
||
|
|
}
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
// ── Step 2: docker-secrets ──────────────────────────────────────────
|
||
|
|
await runner.RunAsync("docker-secrets", async () =>
|
||
|
|
{
|
||
|
|
// Reuse IDockerSecretsService pattern — create secrets via SSH docker CLI
|
||
|
|
var sshHost = await GetSwarmSshHostAsync(settings);
|
||
|
|
using var sshClient = CreateSshClient(sshHost);
|
||
|
|
sshClient.Connect();
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var secrets = new Dictionary<string, string>
|
||
|
|
{
|
||
|
|
[CustomerMysqlPasswordSecretName(abbrev)] = mysqlPassword,
|
||
|
|
[CustomerMysqlUserSecretName(abbrev)] = mysqlUserName,
|
||
|
|
[GlobalMysqlHostSecretName] = await settings.GetAsync(SettingsService.MySqlHost, "localhost"),
|
||
|
|
[GlobalMysqlPortSecretName] = await settings.GetAsync(SettingsService.MySqlPort, "3306"),
|
||
|
|
};
|
||
|
|
|
||
|
|
var created = new List<string>();
|
||
|
|
foreach (var (name, value) in secrets)
|
||
|
|
{
|
||
|
|
// Remove existing secret if present (idempotent rotate)
|
||
|
|
RunSshCommandAllowFailure(sshClient, $"docker secret rm {name}");
|
||
|
|
|
||
|
|
var safeValue = value.Replace("'", "'\\''");
|
||
|
|
var cmd = $"printf '%s' '{safeValue}' | docker secret create {name} -";
|
||
|
|
RunSshCommand(sshClient, cmd);
|
||
|
|
created.Add(name);
|
||
|
|
}
|
||
|
|
|
||
|
|
return $"Docker secrets created: {string.Join(", ", created)}.";
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
sshClient.Disconnect();
|
||
|
|
}
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
// ── Step 3: nfs-dirs ────────────────────────────────────────────────
|
||
|
|
await runner.RunAsync("nfs-dirs", 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 directory creation.";
|
||
|
|
|
||
|
|
var sshHost = await GetSwarmSshHostAsync(settings);
|
||
|
|
using var sshClient = CreateSshClient(sshHost);
|
||
|
|
sshClient.Connect();
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// Build the base path for NFS dirs
|
||
|
|
var export = (nfsExport ?? string.Empty).TrimEnd('/');
|
||
|
|
var folder = (nfsExportFolder ?? string.Empty).Trim('/');
|
||
|
|
var basePath = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
|
||
|
|
|
||
|
|
var subdirs = new[]
|
||
|
|
{
|
||
|
|
$"{abbrev}/cms-custom",
|
||
|
|
$"{abbrev}/cms-backup",
|
||
|
|
$"{abbrev}/cms-library",
|
||
|
|
$"{abbrev}/cms-userscripts",
|
||
|
|
$"{abbrev}/cms-ca-certs",
|
||
|
|
};
|
||
|
|
|
||
|
|
// Create directories via sudo on the NFS host
|
||
|
|
// The swarm node mounts NFS, so we can create directories by
|
||
|
|
// temporarily mounting, creating, then unmounting.
|
||
|
|
var mountPoint = $"/tmp/nfs-provision-{abbrev}";
|
||
|
|
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}");
|
||
|
|
RunSshCommand(sshClient,
|
||
|
|
$"sudo mount -t nfs4 {nfsServer}:{basePath} {mountPoint}");
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
foreach (var subdir in subdirs)
|
||
|
|
{
|
||
|
|
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}/{subdir}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}");
|
||
|
|
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}");
|
||
|
|
}
|
||
|
|
|
||
|
|
return $"NFS directories created under {basePath}: {string.Join(", ", subdirs)}.";
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
sshClient.Disconnect();
|
||
|
|
}
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
// ── Step 4: authentik-provision ─────────────────────────────────────
|
||
|
|
await runner.RunAsync("authentik-provision", async () =>
|
||
|
|
{
|
||
|
|
var instanceUrl = ctx.InstanceUrl;
|
||
|
|
var authFlowSlug = await settings.GetAsync(SettingsService.AuthentikAuthorizationFlowSlug, "default-provider-authorization-implicit-consent");
|
||
|
|
var signingKpId = await settings.GetAsync(SettingsService.AuthentikSigningKeypairId);
|
||
|
|
|
||
|
|
// 1. Create SAML provider
|
||
|
|
var providerResponse = await authentikClient.CreateSamlProviderAsync(new CreateSamlProviderRequest(
|
||
|
|
Name: $"xibo-{abbrev}",
|
||
|
|
AuthorizationFlow: authFlowSlug,
|
||
|
|
AcsUrl: $"{instanceUrl}/saml/acs",
|
||
|
|
Issuer: $"xibo-{abbrev}",
|
||
|
|
SpBinding: "post",
|
||
|
|
Audience: instanceUrl,
|
||
|
|
SigningKp: signingKpId));
|
||
|
|
|
||
|
|
EnsureSuccess(providerResponse);
|
||
|
|
var providerData = providerResponse.Content
|
||
|
|
?? throw new InvalidOperationException("Authentik SAML provider creation returned empty response.");
|
||
|
|
authentikProviderId = providerData["pk"]?.ToString();
|
||
|
|
|
||
|
|
// 2. Create Authentik application linked to provider
|
||
|
|
var appResponse = await authentikClient.CreateApplicationAsync(new CreateAuthentikApplicationRequest(
|
||
|
|
Name: $"xibo-{abbrev}",
|
||
|
|
Slug: $"xibo-{abbrev}",
|
||
|
|
Provider: authentikProviderId!,
|
||
|
|
MetaLaunchUrl: instanceUrl));
|
||
|
|
EnsureSuccess(appResponse);
|
||
|
|
|
||
|
|
// 3. Create 4 tenant groups
|
||
|
|
var groupNames = new[]
|
||
|
|
{
|
||
|
|
$"customer-{abbrev}",
|
||
|
|
$"customer-{abbrev}-viewer",
|
||
|
|
$"customer-{abbrev}-editor",
|
||
|
|
$"customer-{abbrev}-admin",
|
||
|
|
};
|
||
|
|
|
||
|
|
foreach (var groupName in groupNames)
|
||
|
|
{
|
||
|
|
var groupResp = await authentikClient.CreateGroupAsync(new CreateAuthentikGroupRequest(
|
||
|
|
Name: groupName,
|
||
|
|
IsSuperuser: false,
|
||
|
|
Parent: null));
|
||
|
|
EnsureSuccess(groupResp);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. Create invitation flow
|
||
|
|
var inviteResponse = await authentikClient.CreateInvitationAsync(new CreateFlowRequest(
|
||
|
|
Name: $"invite-{abbrev}",
|
||
|
|
SingleUse: true,
|
||
|
|
Expires: DateTimeOffset.UtcNow.AddDays(30)));
|
||
|
|
EnsureSuccess(inviteResponse);
|
||
|
|
|
||
|
|
// Store Authentik provider ID on the Instance entity
|
||
|
|
var instance = await db.Instances.FirstOrDefaultAsync(i => i.Id == ctx.InstanceId, ct);
|
||
|
|
if (instance is not null)
|
||
|
|
{
|
||
|
|
instance.AuthentikProviderId = authentikProviderId;
|
||
|
|
await db.SaveChangesAsync(ct);
|
||
|
|
}
|
||
|
|
|
||
|
|
return $"Authentik SAML provider '{authentikProviderId}', application 'xibo-{abbrev}', " +
|
||
|
|
$"4 groups, and invitation flow created.";
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
// ── Step 5: stack-deploy ────────────────────────────────────────────
|
||
|
|
await runner.RunAsync("stack-deploy", async () =>
|
||
|
|
{
|
||
|
|
// Fetch template
|
||
|
|
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
|
||
|
|
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
|
||
|
|
|
||
|
|
if (string.IsNullOrWhiteSpace(repoUrl))
|
||
|
|
throw new InvalidOperationException("Git template repository URL is not configured.");
|
||
|
|
|
||
|
|
var templateConfig = await gitService.FetchAsync(repoUrl, repoPat);
|
||
|
|
|
||
|
|
// Build render context
|
||
|
|
var renderCtx = new RenderContext
|
||
|
|
{
|
||
|
|
CustomerName = ctx.CompanyName,
|
||
|
|
CustomerAbbrev = abbrev,
|
||
|
|
StackName = stackName,
|
||
|
|
CmsServerName = await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com"),
|
||
|
|
HostHttpPort = 80,
|
||
|
|
CmsImage = await settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"),
|
||
|
|
MemcachedImage = await settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"),
|
||
|
|
QuickChartImage = await settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart"),
|
||
|
|
NewtImage = await settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"),
|
||
|
|
ThemeHostPath = await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme"),
|
||
|
|
MySqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost"),
|
||
|
|
MySqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306"),
|
||
|
|
MySqlDatabase = mysqlDbName,
|
||
|
|
MySqlUser = mysqlUserName,
|
||
|
|
MySqlPassword = mysqlPassword,
|
||
|
|
SmtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty),
|
||
|
|
SmtpUsername = await settings.GetAsync(SettingsService.SmtpUsername, string.Empty),
|
||
|
|
SmtpPassword = await settings.GetAsync(SettingsService.SmtpPassword, string.Empty),
|
||
|
|
SmtpUseTls = await settings.GetAsync(SettingsService.SmtpUseTls, "YES"),
|
||
|
|
SmtpUseStartTls = await settings.GetAsync(SettingsService.SmtpUseStartTls, "YES"),
|
||
|
|
SmtpRewriteDomain = await settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty),
|
||
|
|
SmtpHostname = await settings.GetAsync(SettingsService.SmtpHostname, string.Empty),
|
||
|
|
SmtpFromLineOverride = await settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO"),
|
||
|
|
PhpPostMaxSize = await settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"),
|
||
|
|
PhpUploadMaxFilesize = await settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"),
|
||
|
|
PhpMaxExecutionTime = await settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"),
|
||
|
|
PangolinEndpoint = await settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"),
|
||
|
|
NfsServer = await settings.GetAsync(SettingsService.NfsServer),
|
||
|
|
NfsExport = await settings.GetAsync(SettingsService.NfsExport),
|
||
|
|
NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder),
|
||
|
|
NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions, string.Empty),
|
||
|
|
};
|
||
|
|
|
||
|
|
var composeYaml = composeRenderer.Render(templateConfig.Yaml, renderCtx);
|
||
|
|
|
||
|
|
// Deploy via SSH: pipe compose YAML to docker stack deploy
|
||
|
|
var sshHost = await GetSwarmSshHostAsync(settings);
|
||
|
|
using var sshClient = CreateSshClient(sshHost);
|
||
|
|
sshClient.Connect();
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var safeYaml = composeYaml.Replace("'", "'\\''");
|
||
|
|
var deployCmd = $"printf '%s' '{safeYaml}' | docker stack deploy -c - {stackName}";
|
||
|
|
var result = RunSshCommand(sshClient, deployCmd);
|
||
|
|
return $"Stack '{stackName}' deployed. Output: {result}";
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
sshClient.Disconnect();
|
||
|
|
}
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
// ── Step 6: credential-store ────────────────────────────────────────
|
||
|
|
await runner.RunAsync("credential-store", async () =>
|
||
|
|
{
|
||
|
|
// Bitwarden item structure:
|
||
|
|
// Key: "{abbrev}/mysql-password" → MySQL password for xibo_{abbrev} user
|
||
|
|
// Key: "{abbrev}/mysql-database" → Database name (for reference)
|
||
|
|
// Key: "{abbrev}/mysql-user" → MySQL username (for reference)
|
||
|
|
// All stored in the instance Bitwarden project via IBitwardenSecretService.
|
||
|
|
|
||
|
|
await bws.CreateInstanceSecretAsync(
|
||
|
|
key: $"{abbrev}/mysql-password",
|
||
|
|
value: mysqlPassword,
|
||
|
|
note: $"MySQL password for instance {abbrev}. DB: {mysqlDbName}, User: {mysqlUserName}");
|
||
|
|
|
||
|
|
// Also persist in settings for Phase2 and future redeploys
|
||
|
|
await settings.SetAsync(
|
||
|
|
SettingsService.InstanceMySqlPassword(abbrev),
|
||
|
|
mysqlPassword,
|
||
|
|
SettingsService.CatInstance,
|
||
|
|
isSensitive: true);
|
||
|
|
|
||
|
|
// Clear in-memory password
|
||
|
|
mysqlPassword = string.Empty;
|
||
|
|
|
||
|
|
return $"Credentials stored in Bitwarden for instance '{abbrev}'.";
|
||
|
|
}, ct);
|
||
|
|
|
||
|
|
_logger.LogInformation("Phase1Pipeline completed for job {JobId} (abbrev={Abbrev})", job.Id, abbrev);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────
|
||
|
|
// Helpers
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Reads SSH connection details for the Docker Swarm host from settings.
|
||
|
|
/// Expects settings keys: "Ssh.SwarmHost", "Ssh.SwarmPort", "Ssh.SwarmUser", "Ssh.SwarmKeyPath".
|
||
|
|
/// </summary>
|
||
|
|
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}. " +
|
||
|
|
"Configure Ssh.SwarmKeyPath or Ssh.SwarmPassword in settings.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string GenerateRandomPassword(int length)
|
||
|
|
{
|
||
|
|
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||
|
|
return RandomNumberGenerator.GetString(chars, length);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void EnsureSuccess<T>(Refit.IApiResponse<T> response)
|
||
|
|
{
|
||
|
|
if (!response.IsSuccessStatusCode)
|
||
|
|
throw new InvalidOperationException(
|
||
|
|
$"API call failed with status {response.StatusCode}: {response.Error?.Content}");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>SSH connection details read from Bitwarden settings.</summary>
|
||
|
|
internal sealed record SshConnectionInfo(
|
||
|
|
string Host,
|
||
|
|
int Port,
|
||
|
|
string Username,
|
||
|
|
string? KeyPath,
|
||
|
|
string? Password);
|
||
|
|
}
|