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

This commit is contained in:
Matt Batchelder
2026-02-18 16:15:54 -05:00
parent 45c94b6536
commit 4a903bfd2a
32 changed files with 1474 additions and 2289 deletions

View File

@@ -0,0 +1,343 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260218180240_RenameShareBasePathToShareName")]
partial class RenameShareBasePathToShareName
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CifsExtraOptions")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("CifsServer")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CifsShareName")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsUsername")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class RenameShareBasePathToShareName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "CifsShareBasePath",
table: "CmsInstances",
newName: "CifsShareName");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "CifsShareName",
table: "CmsInstances",
newName: "CifsShareBasePath");
}
}
}

View File

@@ -0,0 +1,347 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260218202617_AddCifsShareFolder")]
partial class AddCifsShareFolder
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CifsExtraOptions")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("CifsServer")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CifsShareFolder")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsShareName")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsUsername")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class AddCifsShareFolder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CifsShareFolder",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CifsShareFolder",
table: "CmsInstances");
}
}
}

View File

@@ -63,7 +63,11 @@ namespace OTSSignsOrchestrator.Core.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CifsShareBasePath")
b.Property<string>("CifsShareFolder")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsShareName")
.HasMaxLength(500)
.HasColumnType("TEXT");

View File

@@ -28,7 +28,11 @@ public class CreateInstanceDto
public string? CifsServer { get; set; }
[MaxLength(500)]
public string? CifsShareBasePath { get; set; }
public string? CifsShareName { get; set; }
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root.</summary>
[MaxLength(500)]
public string? CifsShareFolder { get; set; }
[MaxLength(200)]
public string? CifsUsername { get; set; }

View File

@@ -3,6 +3,5 @@ namespace OTSSignsOrchestrator.Core.Models.DTOs;
public class TemplateConfig
{
public string Yaml { get; set; } = string.Empty;
public List<string> EnvLines { get; set; } = new();
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -30,7 +30,11 @@ public class UpdateInstanceDto
public string? CifsServer { get; set; }
[MaxLength(500)]
public string? CifsShareBasePath { get; set; }
public string? CifsShareName { get; set; }
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root.</summary>
[MaxLength(500)]
public string? CifsShareFolder { get; set; }
[MaxLength(200)]
public string? CifsUsername { get; set; }

View File

@@ -95,7 +95,11 @@ public class CmsInstance
public string? CifsServer { get; set; }
[MaxLength(500)]
public string? CifsShareBasePath { get; set; }
public string? CifsShareName { get; set; }
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root.</summary>
[MaxLength(500)]
public string? CifsShareFolder { get; set; }
[MaxLength(200)]
public string? CifsUsername { get; set; }

View File

@@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.2" />
<PackageReference Include="MySqlConnector" Version="2.5.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />

View File

@@ -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;
}

View File

@@ -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
};
}

View File

@@ -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

View File

@@ -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}");

View File

@@ -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";