Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs
2026-02-18 16:15:54 -05:00

289 lines
12 KiB
C#

using Microsoft.Extensions.Logging;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// 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 <c>template.yml</c>.
/// Call <see cref="GetTemplateYaml"/> to obtain the canonical template to commit to your repo.
/// </summary>
public class ComposeRenderService
{
private readonly ILogger<ComposeRenderService> _logger;
public ComposeRenderService(ILogger<ComposeRenderService> logger)
{
_logger = logger;
}
/// <summary>
/// Substitutes all {{PLACEHOLDER}} tokens in <paramref name="templateYaml"/> and returns
/// the final compose YAML ready for deployment.
/// </summary>
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;
}
/// <summary>
/// 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"
/// </summary>
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}";
}
/// <summary>
/// Returns the canonical <c>template.yml</c> content with all placeholders.
/// Commit this file to the root of your template git repository.
/// </summary>
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
""";
}
/// <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 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; }
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Empty/null = share root.</summary>
public string? CifsShareFolder { get; set; }
public string? CifsUsername { get; set; }
public string? CifsPassword { get; set; }
public string? CifsExtraOptions { get; set; }
}