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; /// /// 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. /// public class ComposeRenderService { private readonly ILogger _logger; public ComposeRenderService(ILogger 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" } }; } } } /// Context object with all inputs needed to render a Compose file. 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 SecretNames { get; set; } = new(); // Legacy — kept for backward compat but no longer used public string TemplateYaml { get; set; } = string.Empty; public Dictionary TemplateEnvValues { get; set; } = new(); public List TemplateEnvLines { get; set; } = new(); public List Constraints { get; set; } = new(); public string LibraryHostPath { get; set; } = string.Empty; }