feat: Add main application views and structure
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:
Matt Batchelder
2026-02-18 10:43:27 -05:00
parent 29b8c23dbb
commit 45c94b6536
149 changed files with 6469 additions and 63498 deletions

View 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;
}

View 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;
}

View 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();
}
}

View 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;
}

View 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; }
}

View 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).
/// Newinstance 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);
}
}

View 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;
}
}
}

View 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; }
}