Remove appsettings.json, rename CifsShareBasePath to CifsShareName, add CifsShareFolder to CmsInstances, and create a template.yml for Docker configuration.
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
@@ -1,17 +1,12 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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
|
||||
{
|
||||
@@ -22,278 +17,220 @@ public class ComposeRenderService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Render(RenderContext ctx)
|
||||
/// <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}", ctx.StackName);
|
||||
_logger.LogInformation("Rendering Compose for stack {StackName} from template", ctx.StackName);
|
||||
|
||||
var root = new YamlMappingNode();
|
||||
if (string.IsNullOrWhiteSpace(templateYaml))
|
||||
throw new ArgumentException("Template YAML is empty. Ensure template.yml exists in the configured git repository.");
|
||||
|
||||
// Version
|
||||
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
|
||||
var cifsOpts = BuildCifsOpts(ctx);
|
||||
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Services ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildServices(YamlMappingNode root, RenderContext ctx)
|
||||
private static string BuildCifsOpts(RenderContext ctx)
|
||||
{
|
||||
var services = new YamlMappingNode();
|
||||
root.Children[new YamlScalarNode("services")] = services;
|
||||
if (string.IsNullOrWhiteSpace(ctx.CifsServer))
|
||||
return string.Empty;
|
||||
|
||||
BuildWebService(services, ctx);
|
||||
BuildMemcachedService(services, ctx);
|
||||
BuildQuickChartService(services, ctx);
|
||||
|
||||
if (ctx.IncludeNewt)
|
||||
BuildNewtService(services, ctx);
|
||||
// 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;
|
||||
}
|
||||
|
||||
private void BuildWebService(YamlMappingNode services, RenderContext ctx)
|
||||
/// <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 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;
|
||||
var name = (shareName ?? string.Empty).Trim('/');
|
||||
var folder = (shareFolder ?? string.Empty).Trim('/');
|
||||
return string.IsNullOrEmpty(folder) ? name : $"{name}/{folder}";
|
||||
}
|
||||
|
||||
private void BuildMemcachedService(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-memcached")] = svc;
|
||||
/// <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;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.MemcachedImage);
|
||||
// ── Canonical template ──────────────────────────────────────────────────
|
||||
|
||||
var command = new YamlSequenceNode(
|
||||
new YamlScalarNode("memcached"),
|
||||
new YamlScalarNode("-m"),
|
||||
new YamlScalarNode("15")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("command")] = command;
|
||||
public const string TemplateYaml =
|
||||
"""
|
||||
# Customer: {{CUSTOMER_NAME}}
|
||||
version: "3.9"
|
||||
|
||||
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;
|
||||
services:
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
|
||||
{
|
||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
|
||||
{ "resources", new YamlMappingNode
|
||||
{ { "limits", new YamlMappingNode { { "memory", "100M" } } } }
|
||||
}
|
||||
};
|
||||
}
|
||||
{{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
|
||||
|
||||
private void BuildQuickChartService(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-quickchart")] = svc;
|
||||
{{ABBREV}}-memcached:
|
||||
image: {{MEMCACHED_IMAGE}}
|
||||
command: [memcached, -m, "15"]
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
aliases:
|
||||
- memcached
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
resources:
|
||||
limits:
|
||||
memory: 100M
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.QuickChartImage);
|
||||
{{ABBREV}}-quickchart:
|
||||
image: {{QUICKCHART_IMAGE}}
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
aliases:
|
||||
- quickchart
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
|
||||
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;
|
||||
{{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
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
|
||||
{
|
||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
|
||||
};
|
||||
}
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
driver: overlay
|
||||
attachable: "false"
|
||||
|
||||
private void BuildNewtService(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-newt")] = svc;
|
||||
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}}
|
||||
|
||||
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" } };
|
||||
}
|
||||
}
|
||||
secrets:
|
||||
{{ABBREV}}-cms-db-password:
|
||||
external: true
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>Context object with all inputs needed to render a Compose file.</summary>
|
||||
@@ -336,26 +273,16 @@ public class RenderContext
|
||||
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? 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; }
|
||||
|
||||
// Secrets to declare as external
|
||||
public List<string> SecretNames { get; set; } = new();
|
||||
|
||||
// Legacy — kept for backward compat but no longer used
|
||||
public string TemplateYaml { get; set; } = string.Empty;
|
||||
public Dictionary<string, string> TemplateEnvValues { get; set; } = new();
|
||||
public List<string> TemplateEnvLines { get; set; } = new();
|
||||
public List<string> Constraints { get; set; } = new();
|
||||
public string LibraryHostPath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -47,22 +47,15 @@ public class GitTemplateService
|
||||
});
|
||||
|
||||
var yamlPath = FindFile(cacheDir, "template.yml");
|
||||
var envPath = FindFile(cacheDir, "template.env");
|
||||
|
||||
if (yamlPath == null)
|
||||
throw new FileNotFoundException("template.yml not found in repository root.");
|
||||
if (envPath == null)
|
||||
throw new FileNotFoundException("template.env not found in repository root.");
|
||||
throw new FileNotFoundException("template.yml not found in repository root. Commit the template file produced by ComposeRenderService.GetTemplateYaml() to the repo root.");
|
||||
|
||||
var yaml = await File.ReadAllTextAsync(yamlPath);
|
||||
var envLines = (await File.ReadAllLinesAsync(envPath))
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l) && !l.TrimStart().StartsWith('#'))
|
||||
.ToList();
|
||||
|
||||
return new TemplateConfig
|
||||
{
|
||||
Yaml = yaml,
|
||||
EnvLines = envLines,
|
||||
FetchedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,30 @@ public interface IDockerCliService
|
||||
Task<DeploymentResultDto> RemoveStackAsync(string stackName);
|
||||
Task<List<StackInfo>> ListStacksAsync();
|
||||
Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName);
|
||||
|
||||
/// <summary>Ensures a directory exists on the target host (equivalent to mkdir -p).</summary>
|
||||
Task<bool> EnsureDirectoryAsync(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the required folders exist on an SMB/CIFS share, creating any that are missing.
|
||||
/// If <paramref name="cifsShareFolder"/> is non-empty, creates it first as a subfolder of the share,
|
||||
/// then creates the volume folders inside it.
|
||||
/// Uses smbclient on the remote host to interact with the share without requiring a mount.
|
||||
/// </summary>
|
||||
Task<bool> EnsureSmbFoldersAsync(
|
||||
string cifsServer,
|
||||
string cifsShareName,
|
||||
string cifsUsername,
|
||||
string cifsPassword,
|
||||
IEnumerable<string> folderNames,
|
||||
string? cifsShareFolder = null);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all Docker volumes whose names start with <paramref name="stackName"/>_.
|
||||
/// Volumes currently in use by running containers will be skipped.
|
||||
/// Safe for CIFS volumes since data lives on the remote share, not in the local volume.
|
||||
/// </summary>
|
||||
Task<bool> RemoveStackVolumesAsync(string stackName);
|
||||
}
|
||||
|
||||
public class StackInfo
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MySqlConnector;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
@@ -72,21 +73,33 @@ public class InstanceService
|
||||
{
|
||||
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
|
||||
|
||||
// ── Check uniqueness ────────────────────────────────────────────
|
||||
// ── Check uniqueness — redirect to update if already present ───
|
||||
var existing = await _db.CmsInstances.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
|
||||
if (existing != null)
|
||||
throw new InvalidOperationException($"Stack '{stackName}' already exists.");
|
||||
{
|
||||
_logger.LogInformation("Instance '{StackName}' already exists in DB — applying stack update instead.", stackName);
|
||||
var updateDto = new UpdateInstanceDto
|
||||
{
|
||||
CifsServer = dto.CifsServer,
|
||||
CifsShareName = dto.CifsShareName,
|
||||
CifsShareFolder = dto.CifsShareFolder,
|
||||
CifsUsername = dto.CifsUsername,
|
||||
CifsPassword = dto.CifsPassword,
|
||||
CifsExtraOptions = dto.CifsExtraOptions,
|
||||
};
|
||||
return await UpdateInstanceAsync(existing.Id, updateDto, userId);
|
||||
}
|
||||
|
||||
// ── 1. Clone template repo (optional) ───────────────────────────
|
||||
// ── 1. Clone / refresh template repo ────────────────────────────
|
||||
var repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
|
||||
var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(repoUrl))
|
||||
{
|
||||
_logger.LogInformation("Cloning template repo: {RepoUrl}", repoUrl);
|
||||
await _git.FetchAsync(repoUrl, repoPat);
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
throw new InvalidOperationException("Git template repository URL is not configured. Set it in Settings → Git Repo URL.");
|
||||
|
||||
_logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl);
|
||||
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
|
||||
|
||||
// ── 2. Generate MySQL password → Docker Swarm secret ────────────
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
@@ -116,7 +129,8 @@ public class InstanceService
|
||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
var cifsServer = dto.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
|
||||
var cifsShareBasePath = dto.CifsShareBasePath ?? await _settings.GetAsync(SettingsService.CifsShareBasePath);
|
||||
var cifsShareName = dto.CifsShareName ?? await _settings.GetAsync(SettingsService.CifsShareName);
|
||||
var cifsShareFolder = dto.CifsShareFolder ?? await _settings.GetAsync(SettingsService.CifsShareFolder);
|
||||
var cifsUsername = dto.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
|
||||
var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
|
||||
var cifsOptions = dto.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
@@ -130,7 +144,7 @@ public class InstanceService
|
||||
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
// ── 4. Render compose YAML ──────────────────────────────────────
|
||||
// ── 4. Render compose YAML from template ────────────────────────
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
@@ -158,20 +172,21 @@ public class InstanceService
|
||||
PhpPostMaxSize = phpPostMaxSize,
|
||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||
IncludeNewt = true,
|
||||
PangolinEndpoint = pangolinEndpoint,
|
||||
NewtId = dto.NewtId,
|
||||
NewtSecret = dto.NewtSecret,
|
||||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsShareName = cifsShareName,
|
||||
CifsShareFolder = cifsShareFolder,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
_logger.LogInformation("CIFS render values: server={CifsServer}, share={CifsShareName}, folder={CifsShareFolder}, user={CifsUsername}",
|
||||
cifsServer, cifsShareName, cifsShareFolder, cifsUsername);
|
||||
|
||||
var composeYaml = _compose.Render(templateConfig.Yaml, renderCtx);
|
||||
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
@@ -180,12 +195,35 @@ public class InstanceService
|
||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// ── 5. Deploy stack ─────────────────────────────────────────────
|
||||
// ── 5. Ensure bind-mount directories exist on the remote host ───
|
||||
if (!string.IsNullOrWhiteSpace(themePath))
|
||||
await _docker.EnsureDirectoryAsync(themePath);
|
||||
|
||||
// ── 5b. Ensure SMB share folders exist ───────────────────────────
|
||||
if (!string.IsNullOrWhiteSpace(cifsServer) && !string.IsNullOrWhiteSpace(cifsShareName))
|
||||
{
|
||||
var smbFolders = new[]
|
||||
{
|
||||
$"{abbrev}-cms-custom",
|
||||
$"{abbrev}-cms-backup",
|
||||
$"{abbrev}-cms-library",
|
||||
$"{abbrev}-cms-userscripts",
|
||||
$"{abbrev}-cms-ca-certs",
|
||||
};
|
||||
_logger.LogInformation("Ensuring SMB share folders exist on //{Server}/{Share}", cifsServer, cifsShareName);
|
||||
await _docker.EnsureSmbFoldersAsync(cifsServer, cifsShareName, cifsUsername ?? string.Empty, cifsPassword ?? string.Empty, smbFolders, cifsShareFolder);
|
||||
}
|
||||
|
||||
// ── 6. Remove stale CIFS volumes so Docker recreates them with current settings ─
|
||||
_logger.LogInformation("Removing stale CIFS volumes for stack {StackName} to ensure fresh mount options", stackName);
|
||||
await _docker.RemoveStackVolumesAsync(stackName);
|
||||
|
||||
// ── 7. Deploy stack ─────────────────────────────────────────────
|
||||
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
||||
|
||||
// ── 6. Record instance ──────────────────────────────────────────
|
||||
// ── 8. Record instance ──────────────────────────────────────────
|
||||
var instance = new CmsInstance
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
@@ -202,7 +240,8 @@ public class InstanceService
|
||||
Status = InstanceStatus.Active,
|
||||
SshHostId = dto.SshHostId,
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsShareName = cifsShareName,
|
||||
CifsShareFolder = cifsShareFolder,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
@@ -220,7 +259,7 @@ public class InstanceService
|
||||
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
|
||||
stackName, instance.Id, sw.ElapsedMilliseconds);
|
||||
|
||||
deployResult.ServiceCount = renderCtx.IncludeNewt ? 4 : 3;
|
||||
deployResult.ServiceCount = 4;
|
||||
deployResult.Message = "Instance deployed successfully.";
|
||||
return deployResult;
|
||||
}
|
||||
@@ -238,13 +277,13 @@ public class InstanceService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates MySQL database and user on external MySQL server via SSH.
|
||||
/// Called by the ViewModel before CreateInstanceAsync since it needs SSH access.
|
||||
/// Creates MySQL database and user on the external MySQL server using a direct TCP connection.
|
||||
/// Admin credentials are sourced from SettingsService (MySql.AdminUser / MySql.AdminPassword).
|
||||
/// The new user's password is passed in and never logged.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
|
||||
string abbrev,
|
||||
string mysqlPassword,
|
||||
Func<string, Task<(int ExitCode, string Stdout, string Stderr)>> runSshCommand)
|
||||
string mysqlPassword)
|
||||
{
|
||||
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
@@ -254,29 +293,65 @@ public class InstanceService
|
||||
var dbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var safePwd = mySqlAdminPassword.Replace("'", "'\\''");
|
||||
var safeUserPwd = mysqlPassword.Replace("'", "'\\''");
|
||||
_logger.LogInformation("Creating MySQL database {Db} and user {User} via direct TCP", dbName, userName);
|
||||
|
||||
var sql = $"CREATE DATABASE IF NOT EXISTS \\`{dbName}\\`; "
|
||||
+ $"CREATE USER IF NOT EXISTS '{userName}'@'%' IDENTIFIED BY '{safeUserPwd}'; "
|
||||
+ $"GRANT ALL PRIVILEGES ON \\`{dbName}\\`.* TO '{userName}'@'%'; "
|
||||
+ $"FLUSH PRIVILEGES;";
|
||||
if (!int.TryParse(mySqlPort, out var port))
|
||||
port = 3306;
|
||||
|
||||
var cmd = $"mysql -h {mySqlHost} -P {mySqlPort} -u {mySqlAdminUser} -p'{safePwd}' -e \"{sql}\" 2>&1";
|
||||
|
||||
_logger.LogInformation("Creating MySQL database {Db} and user {User}", dbName, userName);
|
||||
|
||||
var (exitCode, stdout, stderr) = await runSshCommand(cmd);
|
||||
|
||||
if (exitCode == 0)
|
||||
var csb = new MySqlConnectionStringBuilder
|
||||
{
|
||||
_logger.LogInformation("MySQL database and user created: {Db} / {User}", dbName, userName);
|
||||
Server = mySqlHost,
|
||||
Port = (uint)port,
|
||||
UserID = mySqlAdminUser,
|
||||
Password = mySqlAdminPassword,
|
||||
ConnectionTimeout = 15,
|
||||
SslMode = MySqlSslMode.Preferred,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new MySqlConnection(csb.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
// Backtick-escape database name and single-quote-escape username to handle
|
||||
// any special characters in names. The new user password is passed as a
|
||||
// parameter so it is never interpolated into SQL text.
|
||||
var escapedDb = dbName.Replace("`", "``");
|
||||
var escapedUser = userName.Replace("'", "''");
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"CREATE DATABASE IF NOT EXISTS `{escapedDb}`";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"CREATE USER IF NOT EXISTS '{escapedUser}'@'%' IDENTIFIED BY @pwd";
|
||||
cmd.Parameters.AddWithValue("@pwd", mysqlPassword);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"GRANT ALL PRIVILEGES ON `{escapedDb}`.* TO '{escapedUser}'@'%'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "FLUSH PRIVILEGES";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("MySQL database {Db} and user {User} created successfully", dbName, userName);
|
||||
return (true, $"Database '{dbName}' and user '{userName}' created.");
|
||||
}
|
||||
|
||||
var error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
|
||||
_logger.LogError("MySQL setup failed: {Error}", error);
|
||||
return (false, $"MySQL setup failed: {error.Trim()}");
|
||||
catch (MySqlException ex)
|
||||
{
|
||||
_logger.LogError(ex, "MySQL setup failed for database {Db}", dbName);
|
||||
return (false, $"MySQL setup failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null)
|
||||
@@ -299,7 +374,8 @@ public class InstanceService
|
||||
if (dto.XiboUsername != null) instance.XiboUsername = dto.XiboUsername;
|
||||
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
|
||||
if (dto.CifsServer != null) instance.CifsServer = dto.CifsServer;
|
||||
if (dto.CifsShareBasePath != null) instance.CifsShareBasePath = dto.CifsShareBasePath;
|
||||
if (dto.CifsShareName != null) instance.CifsShareName = dto.CifsShareName;
|
||||
if (dto.CifsShareFolder != null) instance.CifsShareFolder = dto.CifsShareFolder;
|
||||
if (dto.CifsUsername != null) instance.CifsUsername = dto.CifsUsername;
|
||||
if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword;
|
||||
if (dto.CifsExtraOptions != null) instance.CifsExtraOptions = dto.CifsExtraOptions;
|
||||
@@ -324,12 +400,13 @@ public class InstanceService
|
||||
|
||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
// Use per-instance CIFS credentials
|
||||
var cifsServer = instance.CifsServer;
|
||||
var cifsShareBasePath = instance.CifsShareBasePath;
|
||||
var cifsUsername = instance.CifsUsername;
|
||||
var cifsPassword = instance.CifsPassword;
|
||||
var cifsOptions = instance.CifsExtraOptions ?? "file_mode=0777,dir_mode=0777";
|
||||
// Use per-instance CIFS credentials, falling back to global settings
|
||||
var cifsServer = instance.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
|
||||
var cifsShareName = instance.CifsShareName ?? await _settings.GetAsync(SettingsService.CifsShareName);
|
||||
var cifsShareFolder = instance.CifsShareFolder ?? await _settings.GetAsync(SettingsService.CifsShareFolder);
|
||||
var cifsUsername = instance.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
|
||||
var cifsPassword = instance.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
|
||||
var cifsOptions = instance.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
|
||||
var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
@@ -340,6 +417,17 @@ public class InstanceService
|
||||
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
// ── Fetch template from git ─────────────────────────────────────
|
||||
var repoUrl = instance.TemplateRepoUrl;
|
||||
var repoPat = instance.TemplateRepoPat ?? await _settings.GetAsync(SettingsService.GitRepoPat);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
throw new InvalidOperationException("Git template repository URL is not configured. Set it in Settings → Git Repo URL.");
|
||||
|
||||
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
|
||||
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = instance.CustomerName,
|
||||
@@ -367,18 +455,19 @@ public class InstanceService
|
||||
PhpPostMaxSize = phpPostMaxSize,
|
||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||
IncludeNewt = true,
|
||||
PangolinEndpoint = pangolinEndpoint,
|
||||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsShareName = cifsShareName,
|
||||
CifsShareFolder = cifsShareFolder,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
_logger.LogInformation("CIFS render values (update): server={CifsServer}, share={CifsShareName}, folder={CifsShareFolder}, user={CifsUsername}",
|
||||
cifsServer, cifsShareName, cifsShareFolder, cifsUsername);
|
||||
|
||||
var composeYaml = _compose.Render(templateConfig.Yaml, renderCtx);
|
||||
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
@@ -387,6 +476,30 @@ public class InstanceService
|
||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// Ensure bind-mount directories exist on the remote host
|
||||
if (!string.IsNullOrWhiteSpace(instance.ThemeHostPath))
|
||||
await _docker.EnsureDirectoryAsync(instance.ThemeHostPath);
|
||||
|
||||
// Ensure SMB share folders exist
|
||||
if (!string.IsNullOrWhiteSpace(cifsServer) && !string.IsNullOrWhiteSpace(cifsShareName))
|
||||
{
|
||||
var abbrevLower = instance.CustomerAbbrev;
|
||||
var smbFolders = new[]
|
||||
{
|
||||
$"{abbrevLower}-cms-custom",
|
||||
$"{abbrevLower}-cms-backup",
|
||||
$"{abbrevLower}-cms-library",
|
||||
$"{abbrevLower}-cms-userscripts",
|
||||
$"{abbrevLower}-cms-ca-certs",
|
||||
};
|
||||
_logger.LogInformation("Ensuring SMB share folders exist on //{Server}/{Share}", cifsServer, cifsShareName);
|
||||
await _docker.EnsureSmbFoldersAsync(cifsServer, cifsShareName, cifsUsername ?? string.Empty, cifsPassword ?? string.Empty, smbFolders, cifsShareFolder);
|
||||
}
|
||||
|
||||
// Remove stale CIFS volumes so Docker recreates them with current settings
|
||||
_logger.LogInformation("Removing stale CIFS volumes for stack {StackName} to ensure fresh mount options", instance.StackName);
|
||||
await _docker.RemoveStackVolumesAsync(instance.StackName);
|
||||
|
||||
var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");
|
||||
|
||||
@@ -51,7 +51,8 @@ public class SettingsService
|
||||
|
||||
// CIFS
|
||||
public const string CifsServer = "Cifs.Server";
|
||||
public const string CifsShareBasePath = "Cifs.ShareBasePath";
|
||||
public const string CifsShareName = "Cifs.ShareName";
|
||||
public const string CifsShareFolder = "Cifs.ShareFolder";
|
||||
public const string CifsUsername = "Cifs.Username";
|
||||
public const string CifsPassword = "Cifs.Password";
|
||||
public const string CifsOptions = "Cifs.Options";
|
||||
|
||||
Reference in New Issue
Block a user