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 cifsOpts = BuildCifsOpts(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("{{CIFS_SERVER}}", (ctx.CifsServer ?? string.Empty).TrimEnd('/')) .Replace("{{CIFS_SHARE_NAME}}", BuildSharePath(ctx.CifsShareName, ctx.CifsShareFolder)) // Legacy token — was a path component (e.g. "/sharename"), so templates concatenate // it directly after the server: //{{CIFS_SERVER}}{{CIFS_SHARE_BASE_PATH}}/... // We must keep the leading "/" to produce a valid device path. .Replace("{{CIFS_SHARE_BASE_PATH}}", "/" + BuildSharePath(ctx.CifsShareName, ctx.CifsShareFolder)) .Replace("{{CIFS_USERNAME}}", ctx.CifsUsername ?? string.Empty) .Replace("{{CIFS_PASSWORD}}", ctx.CifsPassword ?? string.Empty) .Replace("{{CIFS_OPTS}}", cifsOpts); } private static string BuildCifsOpts(RenderContext ctx) { if (string.IsNullOrWhiteSpace(ctx.CifsServer)) return string.Empty; // vers=3.0 is required by most modern SMB servers (e.g. Hetzner Storage Box). // Without it, mount.cifs may negotiate SMBv1/2.1 which gets rejected as "permission denied". var opts = $"addr={ctx.CifsServer},username={ctx.CifsUsername},password={ctx.CifsPassword},vers=3.0"; if (!string.IsNullOrWhiteSpace(ctx.CifsExtraOptions)) opts += $",{ctx.CifsExtraOptions}"; return opts; } /// /// Combines share name and optional subfolder into a single path segment. /// e.g. ("u548897-sub1", "ots_cms") → "u548897-sub1/ots_cms" /// ("u548897-sub1", null) → "u548897-sub1" /// private static string BuildSharePath(string? shareName, string? shareFolder) { var name = (shareName ?? string.Empty).Trim('/'); var folder = (shareFolder ?? string.Empty).Trim('/'); return string.IsNullOrEmpty(folder) ? name : $"{name}/{folder}"; } /// /// 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 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: cifs device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-custom o: {{CIFS_OPTS}} {{ABBREV}}-cms-backup: driver: local driver_opts: type: cifs device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-backup o: {{CIFS_OPTS}} {{ABBREV}}-cms-library: driver: local driver_opts: type: cifs device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-library o: {{CIFS_OPTS}} {{ABBREV}}-cms-userscripts: driver: local driver_opts: type: cifs device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-userscripts o: {{CIFS_OPTS}} {{ABBREV}}-cms-ca-certs: driver: local driver_opts: type: cifs device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-ca-certs o: {{CIFS_OPTS}} secrets: {{ABBREV}}-cms-db-password: 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; } // CIFS volume settings public string? CifsServer { get; set; } public string? CifsShareName { get; set; } /// Optional subfolder within the share (e.g. "ots_cms"). Empty/null = share root. public string? CifsShareFolder { get; set; } public string? CifsUsername { get; set; } public string? CifsPassword { get; set; } public string? CifsExtraOptions { get; set; } }