using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; namespace OTSSignsOrchestrator.Core.Services; /// /// Renders a Docker Compose file by loading a template from the git repo and substituting /// all {{PLACEHOLDER}} tokens with values from RenderContext. /// The template file expected in the repo is template.yml. /// Call to obtain the canonical template to commit to your repo. /// public class ComposeRenderService { private readonly ILogger _logger; public ComposeRenderService(ILogger logger) { _logger = logger; } /// /// Substitutes all {{PLACEHOLDER}} tokens in and returns /// the final compose YAML ready for deployment. /// public string Render(string templateYaml, RenderContext ctx) { _logger.LogInformation("Rendering Compose for stack {StackName} from template", ctx.StackName); if (string.IsNullOrWhiteSpace(templateYaml)) throw new ArgumentException("Template YAML is empty. Ensure template.yml exists in the configured git repository."); var nfsOpts = BuildNfsOpts(ctx); var nfsDevicePrefix = BuildNfsDevicePrefix(ctx); return templateYaml .Replace("{{ABBREV}}", ctx.CustomerAbbrev) .Replace("{{CUSTOMER_NAME}}", ctx.CustomerName) .Replace("{{STACK_NAME}}", ctx.StackName) .Replace("{{CMS_SERVER_NAME}}", ctx.CmsServerName) .Replace("{{HOST_HTTP_PORT}}", ctx.HostHttpPort.ToString()) .Replace("{{CMS_IMAGE}}", ctx.CmsImage) .Replace("{{MEMCACHED_IMAGE}}", ctx.MemcachedImage) .Replace("{{QUICKCHART_IMAGE}}", ctx.QuickChartImage) .Replace("{{NEWT_IMAGE}}", ctx.NewtImage) .Replace("{{THEME_HOST_PATH}}", ctx.ThemeHostPath) .Replace("{{MYSQL_HOST}}", ctx.MySqlHost) .Replace("{{MYSQL_PORT}}", ctx.MySqlPort) .Replace("{{MYSQL_DATABASE}}", ctx.MySqlDatabase) .Replace("{{MYSQL_USER}}", ctx.MySqlUser) .Replace("{{SMTP_SERVER}}", ctx.SmtpServer) .Replace("{{SMTP_USERNAME}}", ctx.SmtpUsername) .Replace("{{SMTP_PASSWORD}}", ctx.SmtpPassword) .Replace("{{SMTP_USE_TLS}}", ctx.SmtpUseTls) .Replace("{{SMTP_USE_STARTTLS}}", ctx.SmtpUseStartTls) .Replace("{{SMTP_REWRITE_DOMAIN}}", ctx.SmtpRewriteDomain) .Replace("{{SMTP_HOSTNAME}}", ctx.SmtpHostname) .Replace("{{SMTP_FROM_LINE_OVERRIDE}}", ctx.SmtpFromLineOverride) .Replace("{{PHP_POST_MAX_SIZE}}", ctx.PhpPostMaxSize) .Replace("{{PHP_UPLOAD_MAX_FILESIZE}}", ctx.PhpUploadMaxFilesize) .Replace("{{PHP_MAX_EXECUTION_TIME}}", ctx.PhpMaxExecutionTime) .Replace("{{PANGOLIN_ENDPOINT}}", ctx.PangolinEndpoint) .Replace("{{NEWT_ID}}", ctx.NewtId ?? "CONFIGURE_ME") .Replace("{{NEWT_SECRET}}", ctx.NewtSecret ?? "CONFIGURE_ME") .Replace("{{NFS_DEVICE_PREFIX}}", nfsDevicePrefix) .Replace("{{NFS_OPTS}}", nfsOpts) // ── Legacy CIFS token compatibility ───────────────────────────── // External git template repos may still contain old CIFS tokens. // Map them to NFS equivalents so those templates render correctly. .Replace("{{CIFS_SERVER}}", ctx.NfsServer ?? string.Empty) .Replace("{{CIFS_SHARE_NAME}}", BuildLegacySharePath(ctx)) .Replace("{{CIFS_SHARE_BASE_PATH}}", "/" + BuildLegacySharePath(ctx)) .Replace("{{CIFS_USERNAME}}", string.Empty) .Replace("{{CIFS_PASSWORD}}", string.Empty) .Replace("{{CIFS_OPTS}}", nfsOpts); } /// /// Builds a legacy-compatible share path from NFS export + folder for old CIFS templates. /// Maps NFS export/folder to the path that was previously the CIFS share name/folder. /// private static string BuildLegacySharePath(RenderContext ctx) { var export = (ctx.NfsExport ?? string.Empty).Trim('/'); var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/'); return string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}"; } /// /// Builds the NFS mount options string for Docker volume driver_opts. /// Format: "addr=<server>,nfsvers=4,proto=tcp[,extraOptions]". /// private static string BuildNfsOpts(RenderContext ctx) { if (string.IsNullOrWhiteSpace(ctx.NfsServer)) return string.Empty; var opts = $"addr={ctx.NfsServer},nfsvers=4,proto=tcp"; if (!string.IsNullOrWhiteSpace(ctx.NfsExtraOptions)) opts += $",{ctx.NfsExtraOptions}"; return opts; } /// /// Builds the NFS device prefix used in volume definitions. /// Format: ":/export[/subfolder]" (the colon is part of the device path for NFS). /// e.g. ":/srv/nfs/ots_cms" or ":/srv/nfs". /// private static string BuildNfsDevicePrefix(RenderContext ctx) { var export = (ctx.NfsExport ?? string.Empty).Trim('/'); var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/'); var path = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}"; return $":/{path}"; } /// /// Extracts NFS volume device paths from rendered compose YAML, then strips the /// NFS export prefix to return just the relative folder paths that need to exist /// on the NFS server. Works regardless of the template's naming convention /// (hierarchical ots/cms-custom vs flat ots-cms-custom). /// public static List ExtractNfsDeviceFolders(string renderedYaml, string nfsExport, string? nfsExportFolder = null) { // NFS device lines look like: device: ":/mnt/Export/folder/ots-cms-custom" // The colon prefix is the NFS device convention. var matches = Regex.Matches(renderedYaml, @"device:\s*""?:(/[^""]+)""?", RegexOptions.IgnoreCase); var export = (nfsExport ?? string.Empty).Trim('/'); var subFolder = (nfsExportFolder ?? string.Empty).Trim('/'); var prefix = string.IsNullOrEmpty(subFolder) ? $"/{export}" : $"/{export}/{subFolder}"; var folders = new List(); foreach (Match match in matches) { var devicePath = match.Groups[1].Value; // e.g. /mnt/DS-SwarmVolumes/Volumes/ots-cms-custom if (devicePath.StartsWith(prefix + "/")) { // Strip the export prefix to get the relative folder path var relative = devicePath[(prefix.Length + 1)..]; // e.g. ots-cms-custom or ots/cms-custom if (!string.IsNullOrEmpty(relative)) folders.Add(relative); } } return folders; } /// /// Returns the canonical template.yml content with all placeholders. /// Commit this file to the root of your template git repository. /// public static string GetTemplateYaml() => TemplateYaml; // ── Canonical template ────────────────────────────────────────────────── public const string TemplateYaml = """ # Customer: {{CUSTOMER_NAME}} version: "3.9" services: {{ABBREV}}-web: image: {{CMS_IMAGE}} environment: CMS_USE_MEMCACHED: "true" MEMCACHED_HOST: memcached MYSQL_HOST: {{MYSQL_HOST}} MYSQL_PORT: "{{MYSQL_PORT}}" MYSQL_DATABASE: {{MYSQL_DATABASE}} MYSQL_USER: {{MYSQL_USER}} MYSQL_PASSWORD_FILE: /run/secrets/{{ABBREV}}-cms-db-password CMS_SERVER_NAME: {{CMS_SERVER_NAME}} CMS_SMTP_SERVER: {{SMTP_SERVER}} CMS_SMTP_USERNAME: {{SMTP_USERNAME}} CMS_SMTP_PASSWORD: {{SMTP_PASSWORD}} CMS_SMTP_USE_TLS: {{SMTP_USE_TLS}} CMS_SMTP_USE_STARTTLS: {{SMTP_USE_STARTTLS}} CMS_SMTP_REWRITE_DOMAIN: {{SMTP_REWRITE_DOMAIN}} CMS_SMTP_HOSTNAME: {{SMTP_HOSTNAME}} CMS_SMTP_FROM_LINE_OVERRIDE: {{SMTP_FROM_LINE_OVERRIDE}} CMS_PHP_POST_MAX_SIZE: {{PHP_POST_MAX_SIZE}} CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}} CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}" secrets: - {{ABBREV}}-cms-db-password - {{ABBREV}}-cms-db-user - global_mysql_host - global_mysql_port volumes: - {{ABBREV}}-cms-custom:/var/www/cms/custom - {{ABBREV}}-cms-backup:/var/www/backup - {{THEME_HOST_PATH}}:/var/www/cms/web/theme/custom - {{ABBREV}}-cms-library:/var/www/cms/library - {{ABBREV}}-cms-userscripts:/var/www/cms/web/userscripts - {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs ports: - "{{HOST_HTTP_PORT}}:80" networks: {{ABBREV}}-net: aliases: - web deploy: restart_policy: condition: any resources: limits: memory: 1G {{ABBREV}}-memcached: image: {{MEMCACHED_IMAGE}} command: [memcached, -m, "15"] networks: {{ABBREV}}-net: aliases: - memcached deploy: restart_policy: condition: any resources: limits: memory: 100M {{ABBREV}}-quickchart: image: {{QUICKCHART_IMAGE}} networks: {{ABBREV}}-net: aliases: - quickchart deploy: restart_policy: condition: any {{ABBREV}}-newt: image: {{NEWT_IMAGE}} environment: PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}} NEWT_ID: {{NEWT_ID}} NEWT_SECRET: {{NEWT_SECRET}} networks: {{ABBREV}}-net: {} deploy: restart_policy: condition: any networks: {{ABBREV}}-net: driver: overlay attachable: "false" volumes: {{ABBREV}}-cms-custom: driver: local driver_opts: type: nfs device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-custom" o: "{{NFS_OPTS}}" {{ABBREV}}-cms-backup: driver: local driver_opts: type: nfs device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-backup" o: "{{NFS_OPTS}}" {{ABBREV}}-cms-library: driver: local driver_opts: type: nfs device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-library" o: "{{NFS_OPTS}}" {{ABBREV}}-cms-userscripts: driver: local driver_opts: type: nfs device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-userscripts" o: "{{NFS_OPTS}}" {{ABBREV}}-cms-ca-certs: driver: local driver_opts: type: nfs device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-ca-certs" o: "{{NFS_OPTS}}" secrets: {{ABBREV}}-cms-db-password: external: true {{ABBREV}}-cms-db-user: external: true global_mysql_host: external: true global_mysql_port: 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 string PangolinEndpoint { get; set; } = "https://app.pangolin.net"; public string? NewtId { get; set; } public string? NewtSecret { get; set; } // NFS volume settings public string? NfsServer { get; set; } /// NFS export path on the server (e.g. "/srv/nfs" or "/export/data"). public string? NfsExport { get; set; } /// Optional subfolder within the export (e.g. "ots_cms"). Empty/null = export root. public string? NfsExportFolder { get; set; } /// Additional NFS mount options appended after the defaults (nfsvers=4,proto=tcp). public string? NfsExtraOptions { get; set; } }