feat: Add main application views and structure
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
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:
361
OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs
Normal file
361
OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs
Normal file
@@ -0,0 +1,361 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Docker Compose v3.9 YAML for a Xibo CMS stack.
|
||||
/// Combined format: no separate config.env, no MySQL container (external DB),
|
||||
/// CIFS volumes, Newt tunnel service, and inline environment variables.
|
||||
/// </summary>
|
||||
public class ComposeRenderService
|
||||
{
|
||||
private readonly ILogger<ComposeRenderService> _logger;
|
||||
|
||||
public ComposeRenderService(ILogger<ComposeRenderService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Render(RenderContext ctx)
|
||||
{
|
||||
_logger.LogInformation("Rendering Compose for stack: {StackName}", ctx.StackName);
|
||||
|
||||
var root = new YamlMappingNode();
|
||||
|
||||
// Version
|
||||
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
|
||||
|
||||
// Comment — customer name (added as a YAML comment isn't natively supported,
|
||||
// so we prepend it manually after serialization)
|
||||
BuildServices(root, ctx);
|
||||
BuildNetworks(root, ctx);
|
||||
BuildVolumes(root, ctx);
|
||||
BuildSecrets(root, ctx);
|
||||
|
||||
var doc = new YamlDocument(root);
|
||||
var stream = new YamlStream(doc);
|
||||
|
||||
using var writer = new StringWriter();
|
||||
stream.Save(writer, assignAnchors: false);
|
||||
var output = writer.ToString()
|
||||
.Replace("...\n", "").Replace("...", "");
|
||||
|
||||
// Prepend customer name comment
|
||||
output = $"# Customer: {ctx.CustomerName}\n{output}";
|
||||
|
||||
_logger.LogDebug("Compose rendered for {StackName}: {ServiceCount} services",
|
||||
ctx.StackName, 4);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// ── Services ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildServices(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var services = new YamlMappingNode();
|
||||
root.Children[new YamlScalarNode("services")] = services;
|
||||
|
||||
BuildWebService(services, ctx);
|
||||
BuildMemcachedService(services, ctx);
|
||||
BuildQuickChartService(services, ctx);
|
||||
|
||||
if (ctx.IncludeNewt)
|
||||
BuildNewtService(services, ctx);
|
||||
}
|
||||
|
||||
private void BuildWebService(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-web")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.CmsImage);
|
||||
|
||||
// Environment — all config.env values merged inline
|
||||
var env = new YamlMappingNode
|
||||
{
|
||||
{ "CMS_USE_MEMCACHED", "true" },
|
||||
{ "MEMCACHED_HOST", "memcached" },
|
||||
{ "MYSQL_HOST", ctx.MySqlHost },
|
||||
{ "MYSQL_PORT", ctx.MySqlPort },
|
||||
{ "MYSQL_DATABASE", ctx.MySqlDatabase },
|
||||
{ "MYSQL_USER", ctx.MySqlUser },
|
||||
{ "MYSQL_PASSWORD_FILE", $"/run/secrets/{ctx.CustomerAbbrev}-cms-db-password" },
|
||||
{ "CMS_SMTP_SERVER", ctx.SmtpServer },
|
||||
{ "CMS_SMTP_USERNAME", ctx.SmtpUsername },
|
||||
{ "CMS_SMTP_PASSWORD", ctx.SmtpPassword },
|
||||
{ "CMS_SMTP_USE_TLS", ctx.SmtpUseTls },
|
||||
{ "CMS_SMTP_USE_STARTTLS", ctx.SmtpUseStartTls },
|
||||
{ "CMS_SMTP_REWRITE_DOMAIN", ctx.SmtpRewriteDomain },
|
||||
{ "CMS_SMTP_HOSTNAME", ctx.SmtpHostname },
|
||||
{ "CMS_SMTP_FROM_LINE_OVERRIDE", ctx.SmtpFromLineOverride },
|
||||
{ "CMS_SERVER_NAME", ctx.CmsServerName },
|
||||
{ "CMS_PHP_POST_MAX_SIZE", ctx.PhpPostMaxSize },
|
||||
{ "CMS_PHP_UPLOAD_MAX_FILESIZE", ctx.PhpUploadMaxFilesize },
|
||||
{ "CMS_PHP_MAX_EXECUTION_TIME", ctx.PhpMaxExecutionTime },
|
||||
};
|
||||
svc.Children[new YamlScalarNode("environment")] = env;
|
||||
|
||||
// Secrets
|
||||
var secrets = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-db-password")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("secrets")] = secrets;
|
||||
|
||||
// Volumes
|
||||
var volumes = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-custom:/var/www/cms/custom"),
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-backup:/var/www/backup"),
|
||||
new YamlScalarNode($"{ctx.ThemeHostPath}:/var/www/cms/web/theme/custom"),
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-library:/var/www/cms/library"),
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-userscripts:/var/www/cms/web/userscripts"),
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-ca-certs:/var/www/cms/ca-certs")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("volumes")] = volumes;
|
||||
|
||||
// Ports
|
||||
var ports = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{ctx.HostHttpPort}:80")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("ports")] = ports;
|
||||
|
||||
// Networks
|
||||
var webNet = new YamlMappingNode
|
||||
{
|
||||
{ "aliases", new YamlSequenceNode(new YamlScalarNode("web")) }
|
||||
};
|
||||
var networks = new YamlMappingNode();
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = webNet;
|
||||
svc.Children[new YamlScalarNode("networks")] = networks;
|
||||
|
||||
// Deploy
|
||||
var deploy = new YamlMappingNode
|
||||
{
|
||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
|
||||
{ "resources", new YamlMappingNode
|
||||
{ { "limits", new YamlMappingNode { { "memory", "1G" } } } }
|
||||
}
|
||||
};
|
||||
svc.Children[new YamlScalarNode("deploy")] = deploy;
|
||||
}
|
||||
|
||||
private void BuildMemcachedService(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-memcached")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.MemcachedImage);
|
||||
|
||||
var command = new YamlSequenceNode(
|
||||
new YamlScalarNode("memcached"),
|
||||
new YamlScalarNode("-m"),
|
||||
new YamlScalarNode("15")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("command")] = command;
|
||||
|
||||
var mcNet = new YamlMappingNode
|
||||
{
|
||||
{ "aliases", new YamlSequenceNode(new YamlScalarNode("memcached")) }
|
||||
};
|
||||
var networks = new YamlMappingNode();
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = mcNet;
|
||||
svc.Children[new YamlScalarNode("networks")] = networks;
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
|
||||
{
|
||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
|
||||
{ "resources", new YamlMappingNode
|
||||
{ { "limits", new YamlMappingNode { { "memory", "100M" } } } }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void BuildQuickChartService(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-quickchart")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.QuickChartImage);
|
||||
|
||||
var qcNet = new YamlMappingNode
|
||||
{
|
||||
{ "aliases", new YamlSequenceNode(new YamlScalarNode("quickchart")) }
|
||||
};
|
||||
var networks = new YamlMappingNode();
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = qcNet;
|
||||
svc.Children[new YamlScalarNode("networks")] = networks;
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
|
||||
{
|
||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
|
||||
};
|
||||
}
|
||||
|
||||
private void BuildNewtService(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-newt")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.NewtImage);
|
||||
|
||||
var env = new YamlMappingNode
|
||||
{
|
||||
{ "PANGOLIN_ENDPOINT", ctx.PangolinEndpoint },
|
||||
{ "NEWT_ID", ctx.NewtId ?? "CONFIGURE_ME" },
|
||||
{ "NEWT_SECRET", ctx.NewtSecret ?? "CONFIGURE_ME" },
|
||||
};
|
||||
svc.Children[new YamlScalarNode("environment")] = env;
|
||||
|
||||
var networks = new YamlMappingNode();
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = new YamlMappingNode();
|
||||
svc.Children[new YamlScalarNode("networks")] = networks;
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
|
||||
{
|
||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
|
||||
};
|
||||
}
|
||||
|
||||
// ── Networks ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildNetworks(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var netDef = new YamlMappingNode
|
||||
{
|
||||
{ "driver", "overlay" },
|
||||
{ "attachable", "false" }
|
||||
};
|
||||
var networks = new YamlMappingNode();
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = netDef;
|
||||
root.Children[new YamlScalarNode("networks")] = networks;
|
||||
}
|
||||
|
||||
// ── Volumes (CIFS) ──────────────────────────────────────────────────────
|
||||
|
||||
private void BuildVolumes(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var volumes = new YamlMappingNode();
|
||||
root.Children[new YamlScalarNode("volumes")] = volumes;
|
||||
|
||||
var volumeNames = new[]
|
||||
{
|
||||
$"{ctx.CustomerAbbrev}-cms-custom",
|
||||
$"{ctx.CustomerAbbrev}-cms-backup",
|
||||
$"{ctx.CustomerAbbrev}-cms-library",
|
||||
$"{ctx.CustomerAbbrev}-cms-userscripts",
|
||||
$"{ctx.CustomerAbbrev}-cms-ca-certs",
|
||||
};
|
||||
|
||||
foreach (var volName in volumeNames)
|
||||
{
|
||||
if (ctx.UseCifsVolumes && !string.IsNullOrWhiteSpace(ctx.CifsServer))
|
||||
{
|
||||
var device = $"//{ctx.CifsServer}{ctx.CifsShareBasePath}/{volName}";
|
||||
var opts = $"addr={ctx.CifsServer},username={ctx.CifsUsername},password={ctx.CifsPassword}";
|
||||
if (!string.IsNullOrWhiteSpace(ctx.CifsExtraOptions))
|
||||
opts += $",{ctx.CifsExtraOptions}";
|
||||
|
||||
var volDef = new YamlMappingNode
|
||||
{
|
||||
{ "driver", "local" },
|
||||
{ "driver_opts", new YamlMappingNode
|
||||
{
|
||||
{ "type", "cifs" },
|
||||
{ "device", device },
|
||||
{ "o", opts }
|
||||
}
|
||||
}
|
||||
};
|
||||
volumes.Children[new YamlScalarNode(volName)] = volDef;
|
||||
}
|
||||
else
|
||||
{
|
||||
volumes.Children[new YamlScalarNode(volName)] = new YamlMappingNode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Secrets ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildSecrets(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var secrets = new YamlMappingNode();
|
||||
root.Children[new YamlScalarNode("secrets")] = secrets;
|
||||
|
||||
foreach (var secretName in ctx.SecretNames)
|
||||
{
|
||||
secrets.Children[new YamlScalarNode(secretName)] =
|
||||
new YamlMappingNode { { "external", "true" } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Context object with all inputs needed to render a Compose file.</summary>
|
||||
public class RenderContext
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
public string StackName { get; set; } = string.Empty;
|
||||
public string CmsServerName { get; set; } = string.Empty;
|
||||
public int HostHttpPort { get; set; } = 80;
|
||||
|
||||
// Docker images
|
||||
public string CmsImage { get; set; } = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
|
||||
public string MemcachedImage { get; set; } = "memcached:alpine";
|
||||
public string QuickChartImage { get; set; } = "ianw/quickchart";
|
||||
public string NewtImage { get; set; } = "fosrl/newt";
|
||||
|
||||
// Theme bind mount path on host
|
||||
public string ThemeHostPath { get; set; } = string.Empty;
|
||||
|
||||
// MySQL (external server)
|
||||
public string MySqlHost { get; set; } = string.Empty;
|
||||
public string MySqlPort { get; set; } = "3306";
|
||||
public string MySqlDatabase { get; set; } = "cms";
|
||||
public string MySqlUser { get; set; } = "cms";
|
||||
|
||||
// SMTP
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
public string SmtpUsername { get; set; } = string.Empty;
|
||||
public string SmtpPassword { get; set; } = string.Empty;
|
||||
public string SmtpUseTls { get; set; } = "YES";
|
||||
public string SmtpUseStartTls { get; set; } = "YES";
|
||||
public string SmtpRewriteDomain { get; set; } = string.Empty;
|
||||
public string SmtpHostname { get; set; } = string.Empty;
|
||||
public string SmtpFromLineOverride { get; set; } = "NO";
|
||||
|
||||
// PHP settings
|
||||
public string PhpPostMaxSize { get; set; } = "10G";
|
||||
public string PhpUploadMaxFilesize { get; set; } = "10G";
|
||||
public string PhpMaxExecutionTime { get; set; } = "600";
|
||||
|
||||
// Pangolin / Newt
|
||||
public bool IncludeNewt { get; set; } = true;
|
||||
public string PangolinEndpoint { get; set; } = "https://app.pangolin.net";
|
||||
public string? NewtId { get; set; }
|
||||
public string? NewtSecret { get; set; }
|
||||
|
||||
// CIFS volume settings
|
||||
public bool UseCifsVolumes { get; set; }
|
||||
public string? CifsServer { get; set; }
|
||||
public string? CifsShareBasePath { get; set; }
|
||||
public string? CifsUsername { get; set; }
|
||||
public string? CifsPassword { get; set; }
|
||||
public string? CifsExtraOptions { get; set; }
|
||||
|
||||
// Secrets to declare as external
|
||||
public List<string> SecretNames { get; set; } = new();
|
||||
|
||||
// Legacy — kept for backward compat but no longer used
|
||||
public string TemplateYaml { get; set; } = string.Empty;
|
||||
public Dictionary<string, string> TemplateEnvValues { get; set; } = new();
|
||||
public List<string> TemplateEnvLines { get; set; } = new();
|
||||
public List<string> Constraints { get; set; } = new();
|
||||
public string LibraryHostPath { get; set; } = string.Empty;
|
||||
}
|
||||
116
OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs
Normal file
116
OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a rendered Compose YAML before deployment.
|
||||
/// </summary>
|
||||
public class ComposeValidationService
|
||||
{
|
||||
private readonly ILogger<ComposeValidationService> _logger;
|
||||
|
||||
public ComposeValidationService(ILogger<ComposeValidationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ValidationResult Validate(string composeYaml, string? customerAbbrev = null)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
YamlStream yamlStream;
|
||||
try
|
||||
{
|
||||
yamlStream = new YamlStream();
|
||||
using var reader = new StringReader(composeYaml);
|
||||
yamlStream.Load(reader);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"YAML parse error: {ex.Message}");
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
if (yamlStream.Documents.Count == 0)
|
||||
{
|
||||
errors.Add("YAML document is empty.");
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
var root = yamlStream.Documents[0].RootNode as YamlMappingNode;
|
||||
if (root == null)
|
||||
{
|
||||
errors.Add("YAML root is not a mapping node.");
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
if (!HasKey(root, "services"))
|
||||
errors.Add("Missing required top-level key: 'services'.");
|
||||
if (!HasKey(root, "secrets"))
|
||||
warnings.Add("Missing top-level key: 'secrets'. Secrets may not be available.");
|
||||
|
||||
if (HasKey(root, "services") && root.Children[new YamlScalarNode("services")] is YamlMappingNode services)
|
||||
{
|
||||
var presentServices = services.Children.Keys
|
||||
.OfType<YamlScalarNode>()
|
||||
.Select(k => k.Value!)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Determine required service suffixes
|
||||
var requiredSuffixes = new[] { "-web", "-memcached", "-quickchart" };
|
||||
var prefix = customerAbbrev ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(prefix))
|
||||
{
|
||||
foreach (var suffix in requiredSuffixes)
|
||||
{
|
||||
if (!presentServices.Contains($"{prefix}{suffix}"))
|
||||
errors.Add($"Missing required service: '{prefix}{suffix}'.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: at least check that there are web/memcached/quickchart services
|
||||
if (!presentServices.Any(s => s.EndsWith("-web", StringComparison.OrdinalIgnoreCase)))
|
||||
errors.Add("Missing a '-web' service.");
|
||||
if (!presentServices.Any(s => s.EndsWith("-memcached", StringComparison.OrdinalIgnoreCase)))
|
||||
errors.Add("Missing a '-memcached' service.");
|
||||
}
|
||||
|
||||
foreach (var (key, value) in services.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyNode && value is YamlMappingNode svcNode)
|
||||
{
|
||||
if (!HasKey(svcNode, "image"))
|
||||
errors.Add($"Service '{keyNode.Value}' is missing 'image'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secretsNode)
|
||||
{
|
||||
foreach (var (key, value) in secretsNode.Children)
|
||||
{
|
||||
if (value is YamlMappingNode secretNode)
|
||||
{
|
||||
if (!HasKey(secretNode, "external"))
|
||||
warnings.Add($"Secret '{(key as YamlScalarNode)?.Value}' is not external.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
private static bool HasKey(YamlMappingNode node, string key)
|
||||
=> node.Children.ContainsKey(new YamlScalarNode(key));
|
||||
}
|
||||
|
||||
public class ValidationResult
|
||||
{
|
||||
public List<string> Errors { get; set; } = new();
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
}
|
||||
161
OTSSignsOrchestrator.Core/Services/GitTemplateService.cs
Normal file
161
OTSSignsOrchestrator.Core/Services/GitTemplateService.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using LibGit2Sharp;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches template.yml and template.env from a Git repository using LibGit2Sharp.
|
||||
/// Caches clones on disk, keyed by SHA-256 hash of the repo URL.
|
||||
/// </summary>
|
||||
public class GitTemplateService
|
||||
{
|
||||
private readonly GitOptions _options;
|
||||
private readonly ILogger<GitTemplateService> _logger;
|
||||
|
||||
public GitTemplateService(IOptions<GitOptions> options, ILogger<GitTemplateService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TemplateConfig> FetchAsync(string repoUrl, string? pat = null, bool forceRefresh = false)
|
||||
{
|
||||
var cacheKey = ComputeCacheKey(repoUrl);
|
||||
var cacheDir = Path.Combine(_options.CacheDir, cacheKey);
|
||||
|
||||
_logger.LogInformation("Fetching templates from repo (cacheKey={CacheKey}, force={Force})", cacheKey, forceRefresh);
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
if (!Directory.Exists(cacheDir) || !Repository.IsValid(cacheDir))
|
||||
{
|
||||
CloneRepo(repoUrl, pat, cacheDir);
|
||||
}
|
||||
else if (forceRefresh || IsCacheStale(cacheDir))
|
||||
{
|
||||
FetchLatest(repoUrl, pat, cacheDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Using cached clone at {CacheDir}", cacheDir);
|
||||
}
|
||||
});
|
||||
|
||||
var yamlPath = FindFile(cacheDir, "template.yml");
|
||||
var envPath = FindFile(cacheDir, "template.env");
|
||||
|
||||
if (yamlPath == null)
|
||||
throw new FileNotFoundException("template.yml not found in repository root.");
|
||||
if (envPath == null)
|
||||
throw new FileNotFoundException("template.env not found in repository root.");
|
||||
|
||||
var yaml = await File.ReadAllTextAsync(yamlPath);
|
||||
var envLines = (await File.ReadAllLinesAsync(envPath))
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l) && !l.TrimStart().StartsWith('#'))
|
||||
.ToList();
|
||||
|
||||
return new TemplateConfig
|
||||
{
|
||||
Yaml = yaml,
|
||||
EnvLines = envLines,
|
||||
FetchedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private void CloneRepo(string repoUrl, string? pat, string cacheDir)
|
||||
{
|
||||
_logger.LogInformation("Shallow cloning repo to {CacheDir}", cacheDir);
|
||||
|
||||
if (Directory.Exists(cacheDir))
|
||||
Directory.Delete(cacheDir, recursive: true);
|
||||
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var cloneOpts = new CloneOptions { IsBare = false, RecurseSubmodules = false };
|
||||
|
||||
if (!string.IsNullOrEmpty(pat))
|
||||
{
|
||||
cloneOpts.FetchOptions.CredentialsProvider = (_, _, _) =>
|
||||
new UsernamePasswordCredentials { Username = pat, Password = string.Empty };
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Repository.Clone(repoUrl, cacheDir, cloneOpts);
|
||||
}
|
||||
catch (LibGit2SharpException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Git clone failed for repo (cacheKey={CacheKey})", ComputeCacheKey(repoUrl));
|
||||
throw new InvalidOperationException($"Failed to clone repository. Check URL and credentials. Error: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void FetchLatest(string repoUrl, string? pat, string cacheDir)
|
||||
{
|
||||
_logger.LogInformation("Fetching latest from origin in {CacheDir}", cacheDir);
|
||||
|
||||
try
|
||||
{
|
||||
using var repo = new Repository(cacheDir);
|
||||
|
||||
var fetchOpts = new FetchOptions();
|
||||
if (!string.IsNullOrEmpty(pat))
|
||||
{
|
||||
fetchOpts.CredentialsProvider = (_, _, _) =>
|
||||
new UsernamePasswordCredentials { Username = pat, Password = string.Empty };
|
||||
}
|
||||
|
||||
var remote = repo.Network.Remotes["origin"];
|
||||
var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification).ToArray();
|
||||
Commands.Fetch(repo, "origin", refSpecs, fetchOpts, "Auto-fetch for template update");
|
||||
|
||||
var trackingBranch = repo.Head.TrackedBranch;
|
||||
if (trackingBranch != null)
|
||||
{
|
||||
repo.Reset(ResetMode.Hard, trackingBranch.Tip);
|
||||
}
|
||||
|
||||
WriteCacheTimestamp(cacheDir);
|
||||
}
|
||||
catch (LibGit2SharpException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Git fetch failed for repo at {CacheDir}", cacheDir);
|
||||
throw new InvalidOperationException($"Failed to fetch latest templates. Error: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsCacheStale(string cacheDir)
|
||||
{
|
||||
var stampFile = Path.Combine(cacheDir, ".fetch-timestamp");
|
||||
if (!File.Exists(stampFile)) return true;
|
||||
|
||||
if (DateTime.TryParse(File.ReadAllText(stampFile), out var lastFetch))
|
||||
{
|
||||
return (DateTime.UtcNow - lastFetch).TotalMinutes > _options.CacheTtlMinutes;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WriteCacheTimestamp(string cacheDir)
|
||||
{
|
||||
var stampFile = Path.Combine(cacheDir, ".fetch-timestamp");
|
||||
File.WriteAllText(stampFile, DateTime.UtcNow.ToString("O"));
|
||||
}
|
||||
|
||||
private static string? FindFile(string dir, string fileName)
|
||||
{
|
||||
var path = Path.Combine(dir, fileName);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
private static string ComputeCacheKey(string repoUrl)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(repoUrl));
|
||||
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
28
OTSSignsOrchestrator.Core/Services/IDockerCliService.cs
Normal file
28
OTSSignsOrchestrator.Core/Services/IDockerCliService.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for Docker CLI stack operations (deploy, remove, list, inspect).
|
||||
/// Implementations may use local docker CLI or SSH-based remote execution.
|
||||
/// </summary>
|
||||
public interface IDockerCliService
|
||||
{
|
||||
Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false);
|
||||
Task<DeploymentResultDto> RemoveStackAsync(string stackName);
|
||||
Task<List<StackInfo>> ListStacksAsync();
|
||||
Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName);
|
||||
}
|
||||
|
||||
public class StackInfo
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int ServiceCount { get; set; }
|
||||
}
|
||||
|
||||
public class ServiceInfo
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Image { get; set; } = string.Empty;
|
||||
public string Replicas { get; set; } = string.Empty;
|
||||
}
|
||||
19
OTSSignsOrchestrator.Core/Services/IDockerSecretsService.cs
Normal file
19
OTSSignsOrchestrator.Core/Services/IDockerSecretsService.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for Docker Swarm secret operations.
|
||||
/// Implementations may use Docker.DotNet, local CLI, or SSH-based remote execution.
|
||||
/// </summary>
|
||||
public interface IDockerSecretsService
|
||||
{
|
||||
Task<(bool Created, string SecretId)> EnsureSecretAsync(string name, string value, bool rotate = false);
|
||||
Task<List<SecretListItem>> ListSecretsAsync();
|
||||
Task<bool> DeleteSecretAsync(string name);
|
||||
}
|
||||
|
||||
public class SecretListItem
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
536
OTSSignsOrchestrator.Core/Services/InstanceService.cs
Normal file
536
OTSSignsOrchestrator.Core/Services/InstanceService.cs
Normal file
@@ -0,0 +1,536 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
|
||||
/// New‐instance flow:
|
||||
/// 1. Clone template repo to local cache
|
||||
/// 2. Generate MySQL password → create Docker Swarm secret (never persisted locally)
|
||||
/// 3. Create MySQL database + user on external MySQL server via SSH
|
||||
/// 4. Render combined compose YAML (no MySQL container, CIFS volumes, Newt service)
|
||||
/// 5. Deploy stack via SSH
|
||||
/// </summary>
|
||||
public class InstanceService
|
||||
{
|
||||
private readonly XiboContext _db;
|
||||
private readonly GitTemplateService _git;
|
||||
private readonly ComposeRenderService _compose;
|
||||
private readonly ComposeValidationService _validation;
|
||||
private readonly IDockerCliService _docker;
|
||||
private readonly IDockerSecretsService _secrets;
|
||||
private readonly XiboApiService _xibo;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly DockerOptions _dockerOptions;
|
||||
private readonly ILogger<InstanceService> _logger;
|
||||
|
||||
public InstanceService(
|
||||
XiboContext db,
|
||||
GitTemplateService git,
|
||||
ComposeRenderService compose,
|
||||
ComposeValidationService validation,
|
||||
IDockerCliService docker,
|
||||
IDockerSecretsService secrets,
|
||||
XiboApiService xibo,
|
||||
SettingsService settings,
|
||||
IOptions<DockerOptions> dockerOptions,
|
||||
ILogger<InstanceService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_git = git;
|
||||
_compose = compose;
|
||||
_validation = validation;
|
||||
_docker = docker;
|
||||
_secrets = secrets;
|
||||
_xibo = xibo;
|
||||
_settings = settings;
|
||||
_dockerOptions = dockerOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new CMS instance:
|
||||
/// 1. Clone repo 2. Generate secrets 3. Create MySQL DB/user 4. Render compose 5. Deploy
|
||||
/// </summary>
|
||||
public async Task<DeploymentResultDto> CreateInstanceAsync(CreateInstanceDto dto, string? userId = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var opLog = StartOperation(OperationType.Create, userId);
|
||||
var abbrev = dto.CustomerAbbrev.Trim().ToLowerInvariant();
|
||||
var stackName = $"{abbrev}-cms-stack";
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
|
||||
|
||||
// ── Check uniqueness ────────────────────────────────────────────
|
||||
var existing = await _db.CmsInstances.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
|
||||
if (existing != null)
|
||||
throw new InvalidOperationException($"Stack '{stackName}' already exists.");
|
||||
|
||||
// ── 1. Clone template repo (optional) ───────────────────────────
|
||||
var repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
|
||||
var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(repoUrl))
|
||||
{
|
||||
_logger.LogInformation("Cloning template repo: {RepoUrl}", repoUrl);
|
||||
await _git.FetchAsync(repoUrl, repoPat);
|
||||
}
|
||||
|
||||
// ── 2. Generate MySQL password → Docker Swarm secret ────────────
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
var mysqlSecretName = $"{abbrev}-cms-db-password";
|
||||
await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
|
||||
await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName);
|
||||
_logger.LogInformation("Docker secret created: {SecretName}", mysqlSecretName);
|
||||
|
||||
// ── 3. Read settings ────────────────────────────────────────────
|
||||
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
|
||||
var themePath = (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var smtpServer = await _settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||
var smtpUsername = await _settings.GetAsync(SettingsService.SmtpUsername, string.Empty);
|
||||
var smtpPassword = await _settings.GetAsync(SettingsService.SmtpPassword, string.Empty);
|
||||
var smtpUseTls = await _settings.GetAsync(SettingsService.SmtpUseTls, "YES");
|
||||
var smtpUseStartTls = await _settings.GetAsync(SettingsService.SmtpUseStartTls, "YES");
|
||||
var smtpRewriteDomain = await _settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
|
||||
var smtpHostname = await _settings.GetAsync(SettingsService.SmtpHostname, string.Empty);
|
||||
var smtpFromLineOverride = await _settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
|
||||
|
||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
var cifsServer = dto.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
|
||||
var cifsShareBasePath = dto.CifsShareBasePath ?? await _settings.GetAsync(SettingsService.CifsShareBasePath);
|
||||
var cifsUsername = dto.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
|
||||
var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
|
||||
var cifsOptions = dto.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
|
||||
var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
var memcachedImage = await _settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||
var quickChartImage = await _settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||
|
||||
var phpPostMaxSize = await _settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
|
||||
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
// ── 4. Render compose YAML ──────────────────────────────────────
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = 80,
|
||||
CmsImage = cmsImage,
|
||||
MemcachedImage = memcachedImage,
|
||||
QuickChartImage = quickChartImage,
|
||||
NewtImage = newtImage,
|
||||
ThemeHostPath = themePath,
|
||||
MySqlHost = mySqlHost,
|
||||
MySqlPort = mySqlPort,
|
||||
MySqlDatabase = mySqlDbName,
|
||||
MySqlUser = mySqlUser,
|
||||
SmtpServer = smtpServer,
|
||||
SmtpUsername = smtpUsername,
|
||||
SmtpPassword = smtpPassword,
|
||||
SmtpUseTls = smtpUseTls,
|
||||
SmtpUseStartTls = smtpUseStartTls,
|
||||
SmtpRewriteDomain = smtpRewriteDomain,
|
||||
SmtpHostname = smtpHostname,
|
||||
SmtpFromLineOverride = smtpFromLineOverride,
|
||||
PhpPostMaxSize = phpPostMaxSize,
|
||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||
IncludeNewt = true,
|
||||
PangolinEndpoint = pangolinEndpoint,
|
||||
NewtId = dto.NewtId,
|
||||
NewtSecret = dto.NewtSecret,
|
||||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
var validationResult = _validation.Validate(composeYaml, abbrev);
|
||||
if (!validationResult.IsValid)
|
||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// ── 5. Deploy stack ─────────────────────────────────────────────
|
||||
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
||||
|
||||
// ── 6. Record instance ──────────────────────────────────────────
|
||||
var instance = new CmsInstance
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = 80,
|
||||
ThemeHostPath = themePath,
|
||||
LibraryHostPath = $"{abbrev}-cms-library",
|
||||
SmtpServer = smtpServer,
|
||||
SmtpUsername = smtpUsername,
|
||||
TemplateRepoUrl = repoUrl ?? string.Empty,
|
||||
TemplateRepoPat = repoPat,
|
||||
Status = InstanceStatus.Active,
|
||||
SshHostId = dto.SshHostId,
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
};
|
||||
|
||||
_db.CmsInstances.Add(instance);
|
||||
sw.Stop();
|
||||
opLog.InstanceId = instance.Id;
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance deployed: {stackName}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
|
||||
stackName, instance.Id, sw.ElapsedMilliseconds);
|
||||
|
||||
deployResult.ServiceCount = renderCtx.IncludeNewt ? 4 : 3;
|
||||
deployResult.Message = "Instance deployed successfully.";
|
||||
return deployResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
opLog.Status = OperationStatus.Failure;
|
||||
opLog.Message = $"Create failed: {ex.Message}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
_logger.LogError(ex, "Instance create failed: {StackName}", stackName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates MySQL database and user on external MySQL server via SSH.
|
||||
/// Called by the ViewModel before CreateInstanceAsync since it needs SSH access.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
|
||||
string abbrev,
|
||||
string mysqlPassword,
|
||||
Func<string, Task<(int ExitCode, string Stdout, string Stderr)>> runSshCommand)
|
||||
{
|
||||
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);
|
||||
|
||||
var dbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var safePwd = mySqlAdminPassword.Replace("'", "'\\''");
|
||||
var safeUserPwd = mysqlPassword.Replace("'", "'\\''");
|
||||
|
||||
var sql = $"CREATE DATABASE IF NOT EXISTS \\`{dbName}\\`; "
|
||||
+ $"CREATE USER IF NOT EXISTS '{userName}'@'%' IDENTIFIED BY '{safeUserPwd}'; "
|
||||
+ $"GRANT ALL PRIVILEGES ON \\`{dbName}\\`.* TO '{userName}'@'%'; "
|
||||
+ $"FLUSH PRIVILEGES;";
|
||||
|
||||
var cmd = $"mysql -h {mySqlHost} -P {mySqlPort} -u {mySqlAdminUser} -p'{safePwd}' -e \"{sql}\" 2>&1";
|
||||
|
||||
_logger.LogInformation("Creating MySQL database {Db} and user {User}", dbName, userName);
|
||||
|
||||
var (exitCode, stdout, stderr) = await runSshCommand(cmd);
|
||||
|
||||
if (exitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("MySQL database and user created: {Db} / {User}", dbName, userName);
|
||||
return (true, $"Database '{dbName}' and user '{userName}' created.");
|
||||
}
|
||||
|
||||
var error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
|
||||
_logger.LogError("MySQL setup failed: {Error}", error);
|
||||
return (false, $"MySQL setup failed: {error.Trim()}");
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var opLog = StartOperation(OperationType.Update, userId);
|
||||
|
||||
try
|
||||
{
|
||||
var instance = await _db.CmsInstances.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Instance {id} not found.");
|
||||
|
||||
_logger.LogInformation("Updating instance: {StackName} (id={Id})", instance.StackName, id);
|
||||
|
||||
if (dto.TemplateRepoUrl != null) instance.TemplateRepoUrl = dto.TemplateRepoUrl;
|
||||
if (dto.TemplateRepoPat != null) instance.TemplateRepoPat = dto.TemplateRepoPat;
|
||||
if (dto.SmtpServer != null) instance.SmtpServer = dto.SmtpServer;
|
||||
if (dto.SmtpUsername != null) instance.SmtpUsername = dto.SmtpUsername;
|
||||
if (dto.Constraints != null) instance.Constraints = JsonSerializer.Serialize(dto.Constraints);
|
||||
if (dto.XiboUsername != null) instance.XiboUsername = dto.XiboUsername;
|
||||
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
|
||||
if (dto.CifsServer != null) instance.CifsServer = dto.CifsServer;
|
||||
if (dto.CifsShareBasePath != null) instance.CifsShareBasePath = dto.CifsShareBasePath;
|
||||
if (dto.CifsUsername != null) instance.CifsUsername = dto.CifsUsername;
|
||||
if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword;
|
||||
if (dto.CifsExtraOptions != null) instance.CifsExtraOptions = dto.CifsExtraOptions;
|
||||
|
||||
var abbrev = instance.CustomerAbbrev;
|
||||
var mysqlSecretName = $"{abbrev}-cms-db-password";
|
||||
|
||||
// Read current settings for re-render
|
||||
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var smtpServer = instance.SmtpServer;
|
||||
var smtpUsername = instance.SmtpUsername;
|
||||
var smtpPassword = await _settings.GetAsync(SettingsService.SmtpPassword, string.Empty);
|
||||
var smtpUseTls = await _settings.GetAsync(SettingsService.SmtpUseTls, "YES");
|
||||
var smtpUseStartTls = await _settings.GetAsync(SettingsService.SmtpUseStartTls, "YES");
|
||||
var smtpRewriteDomain = await _settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
|
||||
var smtpHostname = await _settings.GetAsync(SettingsService.SmtpHostname, string.Empty);
|
||||
var smtpFromLineOverride = await _settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
|
||||
|
||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
// Use per-instance CIFS credentials
|
||||
var cifsServer = instance.CifsServer;
|
||||
var cifsShareBasePath = instance.CifsShareBasePath;
|
||||
var cifsUsername = instance.CifsUsername;
|
||||
var cifsPassword = instance.CifsPassword;
|
||||
var cifsOptions = instance.CifsExtraOptions ?? "file_mode=0777,dir_mode=0777";
|
||||
|
||||
var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
var memcachedImage = await _settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||
var quickChartImage = await _settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||
|
||||
var phpPostMaxSize = await _settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
|
||||
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = instance.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = instance.StackName,
|
||||
CmsServerName = instance.CmsServerName,
|
||||
HostHttpPort = instance.HostHttpPort,
|
||||
CmsImage = cmsImage,
|
||||
MemcachedImage = memcachedImage,
|
||||
QuickChartImage = quickChartImage,
|
||||
NewtImage = newtImage,
|
||||
ThemeHostPath = instance.ThemeHostPath,
|
||||
MySqlHost = mySqlHost,
|
||||
MySqlPort = mySqlPort,
|
||||
MySqlDatabase = mySqlDbName,
|
||||
MySqlUser = mySqlUser,
|
||||
SmtpServer = smtpServer,
|
||||
SmtpUsername = smtpUsername,
|
||||
SmtpPassword = smtpPassword,
|
||||
SmtpUseTls = smtpUseTls,
|
||||
SmtpUseStartTls = smtpUseStartTls,
|
||||
SmtpRewriteDomain = smtpRewriteDomain,
|
||||
SmtpHostname = smtpHostname,
|
||||
SmtpFromLineOverride = smtpFromLineOverride,
|
||||
PhpPostMaxSize = phpPostMaxSize,
|
||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||
IncludeNewt = true,
|
||||
PangolinEndpoint = pangolinEndpoint,
|
||||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
var validationResult = _validation.Validate(composeYaml, abbrev);
|
||||
if (!validationResult.IsValid)
|
||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");
|
||||
|
||||
instance.UpdatedAt = DateTime.UtcNow;
|
||||
instance.Status = InstanceStatus.Active;
|
||||
|
||||
sw.Stop();
|
||||
opLog.InstanceId = instance.Id;
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance updated: {instance.StackName}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
deployResult.ServiceCount = 4;
|
||||
deployResult.Message = "Instance updated and redeployed.";
|
||||
return deployResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
opLog.Status = OperationStatus.Failure;
|
||||
opLog.Message = $"Update failed: {ex.Message}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
_logger.LogError(ex, "Instance update failed (id={Id})", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> DeleteInstanceAsync(
|
||||
Guid id, bool retainSecrets = false, bool clearXiboCreds = true, string? userId = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var opLog = StartOperation(OperationType.Delete, userId);
|
||||
|
||||
try
|
||||
{
|
||||
var instance = await _db.CmsInstances.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Instance {id} not found.");
|
||||
|
||||
_logger.LogInformation("Deleting instance: {StackName} (id={Id}) retainSecrets={RetainSecrets}",
|
||||
instance.StackName, id, retainSecrets);
|
||||
|
||||
var result = await _docker.RemoveStackAsync(instance.StackName);
|
||||
|
||||
if (!retainSecrets)
|
||||
{
|
||||
var mysqlSecretName = $"{instance.CustomerAbbrev}-cms-db-password";
|
||||
await _secrets.DeleteSecretAsync(mysqlSecretName);
|
||||
var secretMeta = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == mysqlSecretName);
|
||||
if (secretMeta != null)
|
||||
_db.SecretMetadata.Remove(secretMeta);
|
||||
}
|
||||
|
||||
instance.Status = InstanceStatus.Deleted;
|
||||
instance.DeletedAt = DateTime.UtcNow;
|
||||
instance.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (clearXiboCreds)
|
||||
{
|
||||
instance.XiboUsername = null;
|
||||
instance.XiboPassword = null;
|
||||
instance.XiboApiTestStatus = XiboApiTestStatus.Unknown;
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
opLog.InstanceId = instance.Id;
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance deleted: {instance.StackName}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
result.Message = "Instance deleted.";
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
opLog.Status = OperationStatus.Failure;
|
||||
opLog.Message = $"Delete failed: {ex.Message}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
_logger.LogError(ex, "Instance delete failed (id={Id})", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CmsInstance?> GetInstanceAsync(Guid id)
|
||||
=> await _db.CmsInstances.Include(i => i.SshHost).FirstOrDefaultAsync(i => i.Id == id);
|
||||
|
||||
public async Task<(List<CmsInstance> Items, int TotalCount)> ListInstancesAsync(
|
||||
int page = 1, int pageSize = 50, string? filter = null)
|
||||
{
|
||||
var query = _db.CmsInstances.Include(i => i.SshHost).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
query = query.Where(i => i.CustomerName.Contains(filter) || i.StackName.Contains(filter));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query.OrderByDescending(i => i.CreatedAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<XiboTestResult> TestXiboConnectionAsync(Guid id)
|
||||
{
|
||||
var instance = await _db.CmsInstances.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Instance {id} not found.");
|
||||
|
||||
if (string.IsNullOrEmpty(instance.XiboUsername) || string.IsNullOrEmpty(instance.XiboPassword))
|
||||
return new XiboTestResult { IsValid = false, Message = "No Xibo credentials stored." };
|
||||
|
||||
var url = $"http://localhost:{instance.HostHttpPort}";
|
||||
var result = await _xibo.TestConnectionAsync(url, instance.XiboUsername, instance.XiboPassword);
|
||||
instance.XiboApiTestStatus = result.IsValid ? XiboApiTestStatus.Success : XiboApiTestStatus.Failed;
|
||||
instance.XiboApiTestedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task EnsureSecretMetadata(string name, bool isGlobal, string? customerName)
|
||||
{
|
||||
var existing = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == name);
|
||||
if (existing == null)
|
||||
{
|
||||
_db.SecretMetadata.Add(new SecretMetadata
|
||||
{
|
||||
Name = name,
|
||||
IsGlobal = isGlobal,
|
||||
CustomerName = customerName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static OperationLog StartOperation(OperationType type, string? userId)
|
||||
=> new OperationLog { Operation = type, UserId = userId, Status = OperationStatus.Pending };
|
||||
|
||||
private static string GenerateRandomPassword(int length)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||
return RandomNumberGenerator.GetString(chars, length);
|
||||
}
|
||||
}
|
||||
147
OTSSignsOrchestrator.Core/Services/SettingsService.cs
Normal file
147
OTSSignsOrchestrator.Core/Services/SettingsService.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes typed application settings from the AppSetting table.
|
||||
/// Sensitive values are encrypted/decrypted transparently via DataProtection.
|
||||
/// </summary>
|
||||
public class SettingsService
|
||||
{
|
||||
private readonly XiboContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly ILogger<SettingsService> _logger;
|
||||
|
||||
// ── Category constants ─────────────────────────────────────────────────
|
||||
public const string CatGit = "Git";
|
||||
public const string CatMySql = "MySql";
|
||||
public const string CatSmtp = "Smtp";
|
||||
public const string CatPangolin = "Pangolin";
|
||||
public const string CatCifs = "Cifs";
|
||||
public const string CatDefaults = "Defaults";
|
||||
|
||||
// ── Key constants ──────────────────────────────────────────────────────
|
||||
// Git
|
||||
public const string GitRepoUrl = "Git.RepoUrl";
|
||||
public const string GitRepoPat = "Git.RepoPat";
|
||||
|
||||
// MySQL Admin
|
||||
public const string MySqlHost = "MySql.Host";
|
||||
public const string MySqlPort = "MySql.Port";
|
||||
public const string MySqlAdminUser = "MySql.AdminUser";
|
||||
public const string MySqlAdminPassword = "MySql.AdminPassword";
|
||||
|
||||
// SMTP
|
||||
public const string SmtpServer = "Smtp.Server";
|
||||
public const string SmtpPort = "Smtp.Port";
|
||||
public const string SmtpUsername = "Smtp.Username";
|
||||
public const string SmtpPassword = "Smtp.Password";
|
||||
public const string SmtpUseTls = "Smtp.UseTls";
|
||||
public const string SmtpUseStartTls = "Smtp.UseStartTls";
|
||||
public const string SmtpRewriteDomain = "Smtp.RewriteDomain";
|
||||
public const string SmtpHostname = "Smtp.Hostname";
|
||||
public const string SmtpFromLineOverride = "Smtp.FromLineOverride";
|
||||
|
||||
// Pangolin
|
||||
public const string PangolinEndpoint = "Pangolin.Endpoint";
|
||||
|
||||
// CIFS
|
||||
public const string CifsServer = "Cifs.Server";
|
||||
public const string CifsShareBasePath = "Cifs.ShareBasePath";
|
||||
public const string CifsUsername = "Cifs.Username";
|
||||
public const string CifsPassword = "Cifs.Password";
|
||||
public const string CifsOptions = "Cifs.Options";
|
||||
|
||||
// Instance Defaults
|
||||
public const string DefaultCmsImage = "Defaults.CmsImage";
|
||||
public const string DefaultNewtImage = "Defaults.NewtImage";
|
||||
public const string DefaultMemcachedImage = "Defaults.MemcachedImage";
|
||||
public const string DefaultQuickChartImage = "Defaults.QuickChartImage";
|
||||
public const string DefaultCmsServerNameTemplate = "Defaults.CmsServerNameTemplate";
|
||||
public const string DefaultThemeHostPath = "Defaults.ThemeHostPath";
|
||||
public const string DefaultMySqlDbTemplate = "Defaults.MySqlDbTemplate";
|
||||
public const string DefaultMySqlUserTemplate = "Defaults.MySqlUserTemplate";
|
||||
public const string DefaultPhpPostMaxSize = "Defaults.PhpPostMaxSize";
|
||||
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
||||
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
||||
|
||||
public SettingsService(
|
||||
XiboContext db,
|
||||
IDataProtectionProvider dataProtection,
|
||||
ILogger<SettingsService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_protector = dataProtection.CreateProtector("OTSSignsOrchestrator.Settings");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Get a single setting value, decrypting if sensitive.</summary>
|
||||
public async Task<string?> GetAsync(string key)
|
||||
{
|
||||
var setting = await _db.AppSettings.FindAsync(key);
|
||||
if (setting == null) return null;
|
||||
return setting.IsSensitive && setting.Value != null
|
||||
? Unprotect(setting.Value)
|
||||
: setting.Value;
|
||||
}
|
||||
|
||||
/// <summary>Get a setting with a fallback default.</summary>
|
||||
public async Task<string> GetAsync(string key, string defaultValue)
|
||||
=> await GetAsync(key) ?? defaultValue;
|
||||
|
||||
/// <summary>Set a single setting, encrypting if sensitive.</summary>
|
||||
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
|
||||
{
|
||||
var setting = await _db.AppSettings.FindAsync(key);
|
||||
if (setting == null)
|
||||
{
|
||||
setting = new AppSetting { Key = key, Category = category, IsSensitive = isSensitive };
|
||||
_db.AppSettings.Add(setting);
|
||||
}
|
||||
|
||||
setting.Value = isSensitive && value != null ? _protector.Protect(value) : value;
|
||||
setting.IsSensitive = isSensitive;
|
||||
setting.Category = category;
|
||||
setting.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Save multiple settings in a single transaction.</summary>
|
||||
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
|
||||
{
|
||||
foreach (var (key, value, category, isSensitive) in settings)
|
||||
await SetAsync(key, value, category, isSensitive);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
_logger.LogInformation("Saved {Count} setting(s)",
|
||||
settings is ICollection<(string, string?, string, bool)> c ? c.Count : -1);
|
||||
}
|
||||
|
||||
/// <summary>Get all settings in a category (values decrypted).</summary>
|
||||
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
|
||||
{
|
||||
var settings = await _db.AppSettings
|
||||
.Where(s => s.Category == category)
|
||||
.ToListAsync();
|
||||
|
||||
return settings.ToDictionary(
|
||||
s => s.Key,
|
||||
s => s.IsSensitive && s.Value != null ? Unprotect(s.Value) : s.Value);
|
||||
}
|
||||
|
||||
private string? Unprotect(string protectedValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _protector.Unprotect(protectedValue);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to unprotect setting value — returning null");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
OTSSignsOrchestrator.Core/Services/XiboApiService.cs
Normal file
90
OTSSignsOrchestrator.Core/Services/XiboApiService.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests connectivity to deployed Xibo CMS instances using OAuth2.
|
||||
/// </summary>
|
||||
public class XiboApiService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly XiboOptions _options;
|
||||
private readonly ILogger<XiboApiService> _logger;
|
||||
|
||||
public XiboApiService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<XiboOptions> options,
|
||||
ILogger<XiboApiService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string username, string password)
|
||||
{
|
||||
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
||||
|
||||
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||
client.Timeout = TimeSpan.FromSeconds(_options.TestConnectionTimeoutSeconds);
|
||||
|
||||
try
|
||||
{
|
||||
var baseUrl = instanceUrl.TrimEnd('/');
|
||||
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
||||
|
||||
var formContent = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||
new KeyValuePair<string, string>("client_id", username),
|
||||
new KeyValuePair<string, string>("client_secret", password)
|
||||
});
|
||||
|
||||
var response = await client.PostAsync(tokenUrl, formContent);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
|
||||
return new XiboTestResult
|
||||
{
|
||||
IsValid = true,
|
||||
Message = "Connected successfully.",
|
||||
HttpStatus = (int)response.StatusCode
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
|
||||
instanceUrl, (int)response.StatusCode);
|
||||
|
||||
return new XiboTestResult
|
||||
{
|
||||
IsValid = false,
|
||||
Message = response.StatusCode switch
|
||||
{
|
||||
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
|
||||
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
|
||||
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
|
||||
_ => $"Unexpected response: {(int)response.StatusCode}"
|
||||
},
|
||||
HttpStatus = (int)response.StatusCode
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return new XiboTestResult { IsValid = false, Message = "Connection timed out." };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class XiboTestResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int HttpStatus { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user