2026-02-19 08:27:54 -05:00
|
|
|
using System.Text.RegularExpressions;
|
2026-02-18 10:43:27 -05:00
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
|
|
|
|
|
namespace OTSSignsOrchestrator.Core.Services;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-02-18 16:15:54 -05:00
|
|
|
/// 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.
|
2026-02-18 10:43:27 -05:00
|
|
|
/// </summary>
|
|
|
|
|
public class ComposeRenderService
|
|
|
|
|
{
|
|
|
|
|
private readonly ILogger<ComposeRenderService> _logger;
|
|
|
|
|
|
|
|
|
|
public ComposeRenderService(ILogger<ComposeRenderService> logger)
|
|
|
|
|
{
|
|
|
|
|
_logger = logger;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 16:15:54 -05:00
|
|
|
/// <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)
|
2026-02-18 10:43:27 -05:00
|
|
|
{
|
2026-02-18 16:15:54 -05:00
|
|
|
_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.");
|
|
|
|
|
|
2026-02-19 08:27:54 -05:00
|
|
|
var nfsOpts = BuildNfsOpts(ctx);
|
|
|
|
|
var nfsDevicePrefix = BuildNfsDevicePrefix(ctx);
|
2026-02-18 16:15:54 -05:00
|
|
|
|
|
|
|
|
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)
|
2026-02-24 22:32:22 -05:00
|
|
|
.Replace("{{MYSQL_PASSWORD}}", ctx.MySqlPassword)
|
2026-02-18 16:15:54 -05:00
|
|
|
.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")
|
2026-02-19 08:27:54 -05:00
|
|
|
.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);
|
2026-02-18 10:43:27 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 08:27:54 -05:00
|
|
|
/// <summary>
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static string BuildLegacySharePath(RenderContext ctx)
|
2026-02-18 10:43:27 -05:00
|
|
|
{
|
2026-02-19 08:27:54 -05:00
|
|
|
var export = (ctx.NfsExport ?? string.Empty).Trim('/');
|
|
|
|
|
var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/');
|
|
|
|
|
return string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Builds the NFS mount options string for Docker volume driver_opts.
|
|
|
|
|
/// Format: "addr=<server>,nfsvers=4,proto=tcp[,extraOptions]".
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static string BuildNfsOpts(RenderContext ctx)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(ctx.NfsServer))
|
2026-02-18 16:15:54 -05:00
|
|
|
return string.Empty;
|
|
|
|
|
|
2026-02-19 08:27:54 -05:00
|
|
|
var opts = $"addr={ctx.NfsServer},nfsvers=4,proto=tcp";
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(ctx.NfsExtraOptions))
|
|
|
|
|
opts += $",{ctx.NfsExtraOptions}";
|
2026-02-18 16:15:54 -05:00
|
|
|
return opts;
|
2026-02-18 10:43:27 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-18 16:15:54 -05:00
|
|
|
/// <summary>
|
2026-02-19 08:27:54 -05:00
|
|
|
/// 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".
|
2026-02-18 16:15:54 -05:00
|
|
|
/// </summary>
|
2026-02-19 08:27:54 -05:00
|
|
|
private static string BuildNfsDevicePrefix(RenderContext ctx)
|
2026-02-18 10:43:27 -05:00
|
|
|
{
|
2026-02-19 08:27:54 -05:00
|
|
|
var export = (ctx.NfsExport ?? string.Empty).Trim('/');
|
|
|
|
|
var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/');
|
|
|
|
|
var path = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
|
|
|
|
|
return $":/{path}";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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 <c>ots/cms-custom</c> vs flat <c>ots-cms-custom</c>).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static List<string> 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<string>();
|
|
|
|
|
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;
|
2026-02-18 10:43:27 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-18 16:15:54 -05:00
|
|
|
/// <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}}
|
2026-02-24 22:32:22 -05:00
|
|
|
MYSQL_PASSWORD: {{MYSQL_PASSWORD}}
|
2026-02-18 16:15:54 -05:00
|
|
|
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:
|
2026-02-19 08:27:54 -05:00
|
|
|
- {{ABBREV}}-cms-db-user
|
|
|
|
|
- global_mysql_host
|
|
|
|
|
- global_mysql_port
|
2026-02-18 16:15:54 -05:00
|
|
|
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:
|
2026-02-19 08:27:54 -05:00
|
|
|
type: nfs
|
|
|
|
|
device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-custom"
|
|
|
|
|
o: "{{NFS_OPTS}}"
|
2026-02-18 16:15:54 -05:00
|
|
|
{{ABBREV}}-cms-backup:
|
|
|
|
|
driver: local
|
|
|
|
|
driver_opts:
|
2026-02-19 08:27:54 -05:00
|
|
|
type: nfs
|
|
|
|
|
device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-backup"
|
|
|
|
|
o: "{{NFS_OPTS}}"
|
2026-02-18 16:15:54 -05:00
|
|
|
{{ABBREV}}-cms-library:
|
|
|
|
|
driver: local
|
|
|
|
|
driver_opts:
|
2026-02-19 08:27:54 -05:00
|
|
|
type: nfs
|
|
|
|
|
device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-library"
|
|
|
|
|
o: "{{NFS_OPTS}}"
|
2026-02-18 16:15:54 -05:00
|
|
|
{{ABBREV}}-cms-userscripts:
|
|
|
|
|
driver: local
|
|
|
|
|
driver_opts:
|
2026-02-19 08:27:54 -05:00
|
|
|
type: nfs
|
|
|
|
|
device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-userscripts"
|
|
|
|
|
o: "{{NFS_OPTS}}"
|
2026-02-18 16:15:54 -05:00
|
|
|
{{ABBREV}}-cms-ca-certs:
|
|
|
|
|
driver: local
|
|
|
|
|
driver_opts:
|
2026-02-19 08:27:54 -05:00
|
|
|
type: nfs
|
|
|
|
|
device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-ca-certs"
|
|
|
|
|
o: "{{NFS_OPTS}}"
|
2026-02-18 16:15:54 -05:00
|
|
|
|
|
|
|
|
secrets:
|
2026-02-19 08:27:54 -05:00
|
|
|
{{ABBREV}}-cms-db-user:
|
|
|
|
|
external: true
|
|
|
|
|
global_mysql_host:
|
|
|
|
|
external: true
|
|
|
|
|
global_mysql_port:
|
|
|
|
|
external: true
|
2026-02-18 16:15:54 -05:00
|
|
|
""";
|
2026-02-18 10:43:27 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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";
|
2026-02-24 22:32:22 -05:00
|
|
|
public string MySqlPassword { get; set; } = string.Empty;
|
2026-02-18 10:43:27 -05:00
|
|
|
|
|
|
|
|
// 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; }
|
|
|
|
|
|
2026-02-19 08:27:54 -05:00
|
|
|
// NFS volume settings
|
|
|
|
|
public string? NfsServer { get; set; }
|
|
|
|
|
/// <summary>NFS export path on the server (e.g. "/srv/nfs" or "/export/data").</summary>
|
|
|
|
|
public string? NfsExport { get; set; }
|
|
|
|
|
/// <summary>Optional subfolder within the export (e.g. "ots_cms"). Empty/null = export root.</summary>
|
|
|
|
|
public string? NfsExportFolder { get; set; }
|
|
|
|
|
/// <summary>Additional NFS mount options appended after the defaults (nfsvers=4,proto=tcp).</summary>
|
|
|
|
|
public string? NfsExtraOptions { get; set; }
|
2026-02-18 10:43:27 -05:00
|
|
|
}
|