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