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

Submodule .template-cache/2dc03e2b2b45fef3 added at 07ab87bc65

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) .HasMaxLength(200)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("CifsShareBasePath") b.Property<string>("CifsShareFolder")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsShareName")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");

View File

@@ -28,7 +28,11 @@ public class CreateInstanceDto
public string? CifsServer { get; set; } public string? CifsServer { get; set; }
[MaxLength(500)] [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)] [MaxLength(200)]
public string? CifsUsername { get; set; } public string? CifsUsername { get; set; }

View File

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

View File

@@ -30,7 +30,11 @@ public class UpdateInstanceDto
public string? CifsServer { get; set; } public string? CifsServer { get; set; }
[MaxLength(500)] [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)] [MaxLength(200)]
public string? CifsUsername { get; set; } public string? CifsUsername { get; set; }

View File

@@ -95,7 +95,11 @@ public class CmsInstance
public string? CifsServer { get; set; } public string? CifsServer { get; set; }
[MaxLength(500)] [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)] [MaxLength(200)]
public string? CifsUsername { get; set; } public string? CifsUsername { get; set; }

View File

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

View File

@@ -1,17 +1,12 @@
using Microsoft.Extensions.Logging; 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; namespace OTSSignsOrchestrator.Core.Services;
/// <summary> /// <summary>
/// Generates a Docker Compose v3.9 YAML for a Xibo CMS stack. /// Renders a Docker Compose file by loading a template from the git repo and substituting
/// Combined format: no separate config.env, no MySQL container (external DB), /// all {{PLACEHOLDER}} tokens with values from RenderContext.
/// CIFS volumes, Newt tunnel service, and inline environment variables. /// 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> /// </summary>
public class ComposeRenderService public class ComposeRenderService
{ {
@@ -22,278 +17,220 @@ public class ComposeRenderService
_logger = logger; _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 var cifsOpts = BuildCifsOpts(ctx);
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
// Comment — customer name (added as a YAML comment isn't natively supported, return templateYaml
// so we prepend it manually after serialization) .Replace("{{ABBREV}}", ctx.CustomerAbbrev)
BuildServices(root, ctx); .Replace("{{CUSTOMER_NAME}}", ctx.CustomerName)
BuildNetworks(root, ctx); .Replace("{{STACK_NAME}}", ctx.StackName)
BuildVolumes(root, ctx); .Replace("{{CMS_SERVER_NAME}}", ctx.CmsServerName)
BuildSecrets(root, ctx); .Replace("{{HOST_HTTP_PORT}}", ctx.HostHttpPort.ToString())
.Replace("{{CMS_IMAGE}}", ctx.CmsImage)
var doc = new YamlDocument(root); .Replace("{{MEMCACHED_IMAGE}}", ctx.MemcachedImage)
var stream = new YamlStream(doc); .Replace("{{QUICKCHART_IMAGE}}", ctx.QuickChartImage)
.Replace("{{NEWT_IMAGE}}", ctx.NewtImage)
using var writer = new StringWriter(); .Replace("{{THEME_HOST_PATH}}", ctx.ThemeHostPath)
stream.Save(writer, assignAnchors: false); .Replace("{{MYSQL_HOST}}", ctx.MySqlHost)
var output = writer.ToString() .Replace("{{MYSQL_PORT}}", ctx.MySqlPort)
.Replace("...\n", "").Replace("...", ""); .Replace("{{MYSQL_DATABASE}}", ctx.MySqlDatabase)
.Replace("{{MYSQL_USER}}", ctx.MySqlUser)
// Prepend customer name comment .Replace("{{SMTP_SERVER}}", ctx.SmtpServer)
output = $"# Customer: {ctx.CustomerName}\n{output}"; .Replace("{{SMTP_USERNAME}}", ctx.SmtpUsername)
.Replace("{{SMTP_PASSWORD}}", ctx.SmtpPassword)
_logger.LogDebug("Compose rendered for {StackName}: {ServiceCount} services", .Replace("{{SMTP_USE_TLS}}", ctx.SmtpUseTls)
ctx.StackName, 4); .Replace("{{SMTP_USE_STARTTLS}}", ctx.SmtpUseStartTls)
.Replace("{{SMTP_REWRITE_DOMAIN}}", ctx.SmtpRewriteDomain)
return output; .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 static string BuildCifsOpts(RenderContext ctx)
private void BuildServices(YamlMappingNode root, RenderContext ctx)
{ {
var services = new YamlMappingNode(); if (string.IsNullOrWhiteSpace(ctx.CifsServer))
root.Children[new YamlScalarNode("services")] = services; return string.Empty;
BuildWebService(services, ctx); // vers=3.0 is required by most modern SMB servers (e.g. Hetzner Storage Box).
BuildMemcachedService(services, ctx); // Without it, mount.cifs may negotiate SMBv1/2.1 which gets rejected as "permission denied".
BuildQuickChartService(services, ctx); var opts = $"addr={ctx.CifsServer},username={ctx.CifsUsername},password={ctx.CifsPassword},vers=3.0";
if (ctx.IncludeNewt)
BuildNewtService(services, ctx);
}
private void BuildWebService(YamlMappingNode services, RenderContext ctx)
{
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;
}
private void BuildMemcachedService(YamlMappingNode services, RenderContext ctx)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-memcached")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.MemcachedImage);
var command = new YamlSequenceNode(
new YamlScalarNode("memcached"),
new YamlScalarNode("-m"),
new YamlScalarNode("15")
);
svc.Children[new YamlScalarNode("command")] = command;
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;
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
{
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
{ "resources", new YamlMappingNode
{ { "limits", new YamlMappingNode { { "memory", "100M" } } } }
}
};
}
private void BuildQuickChartService(YamlMappingNode services, RenderContext ctx)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-quickchart")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.QuickChartImage);
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;
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
{
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
};
}
private void BuildNewtService(YamlMappingNode services, RenderContext ctx)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-newt")] = svc;
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)) if (!string.IsNullOrWhiteSpace(ctx.CifsExtraOptions))
opts += $",{ctx.CifsExtraOptions}"; opts += $",{ctx.CifsExtraOptions}";
return opts;
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 ───────────────────────────────────────────────────────────── /// <summary>
/// Combines share name and optional subfolder into a single path segment.
private void BuildSecrets(YamlMappingNode root, RenderContext ctx) /// 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 secrets = new YamlMappingNode(); var name = (shareName ?? string.Empty).Trim('/');
root.Children[new YamlScalarNode("secrets")] = secrets; var folder = (shareFolder ?? string.Empty).Trim('/');
return string.IsNullOrEmpty(folder) ? name : $"{name}/{folder}";
}
foreach (var secretName in ctx.SecretNames) /// <summary>
{ /// Returns the canonical <c>template.yml</c> content with all placeholders.
secrets.Children[new YamlScalarNode(secretName)] = /// Commit this file to the root of your template git repository.
new YamlMappingNode { { "external", "true" } }; /// </summary>
} public static string GetTemplateYaml() => TemplateYaml;
}
// ── Canonical template ──────────────────────────────────────────────────
public const string TemplateYaml =
"""
# Customer: {{CUSTOMER_NAME}}
version: "3.9"
services:
{{ABBREV}}-web:
image: {{CMS_IMAGE}}
environment:
CMS_USE_MEMCACHED: "true"
MEMCACHED_HOST: memcached
MYSQL_HOST: {{MYSQL_HOST}}
MYSQL_PORT: "{{MYSQL_PORT}}"
MYSQL_DATABASE: {{MYSQL_DATABASE}}
MYSQL_USER: {{MYSQL_USER}}
MYSQL_PASSWORD_FILE: /run/secrets/{{ABBREV}}-cms-db-password
CMS_SERVER_NAME: {{CMS_SERVER_NAME}}
CMS_SMTP_SERVER: {{SMTP_SERVER}}
CMS_SMTP_USERNAME: {{SMTP_USERNAME}}
CMS_SMTP_PASSWORD: {{SMTP_PASSWORD}}
CMS_SMTP_USE_TLS: {{SMTP_USE_TLS}}
CMS_SMTP_USE_STARTTLS: {{SMTP_USE_STARTTLS}}
CMS_SMTP_REWRITE_DOMAIN: {{SMTP_REWRITE_DOMAIN}}
CMS_SMTP_HOSTNAME: {{SMTP_HOSTNAME}}
CMS_SMTP_FROM_LINE_OVERRIDE: {{SMTP_FROM_LINE_OVERRIDE}}
CMS_PHP_POST_MAX_SIZE: {{PHP_POST_MAX_SIZE}}
CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}}
CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}"
secrets:
- {{ABBREV}}-cms-db-password
volumes:
- {{ABBREV}}-cms-custom:/var/www/cms/custom
- {{ABBREV}}-cms-backup:/var/www/backup
- {{THEME_HOST_PATH}}:/var/www/cms/web/theme/custom
- {{ABBREV}}-cms-library:/var/www/cms/library
- {{ABBREV}}-cms-userscripts:/var/www/cms/web/userscripts
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
ports:
- "{{HOST_HTTP_PORT}}:80"
networks:
{{ABBREV}}-net:
aliases:
- web
deploy:
restart_policy:
condition: any
resources:
limits:
memory: 1G
{{ABBREV}}-memcached:
image: {{MEMCACHED_IMAGE}}
command: [memcached, -m, "15"]
networks:
{{ABBREV}}-net:
aliases:
- memcached
deploy:
restart_policy:
condition: any
resources:
limits:
memory: 100M
{{ABBREV}}-quickchart:
image: {{QUICKCHART_IMAGE}}
networks:
{{ABBREV}}-net:
aliases:
- quickchart
deploy:
restart_policy:
condition: any
{{ABBREV}}-newt:
image: {{NEWT_IMAGE}}
environment:
PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}}
NEWT_ID: {{NEWT_ID}}
NEWT_SECRET: {{NEWT_SECRET}}
networks:
{{ABBREV}}-net: {}
deploy:
restart_policy:
condition: any
networks:
{{ABBREV}}-net:
driver: overlay
attachable: "false"
volumes:
{{ABBREV}}-cms-custom:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-custom
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-backup:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-backup
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-library:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-library
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-userscripts:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-userscripts
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-ca-certs:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-ca-certs
o: {{CIFS_OPTS}}
secrets:
{{ABBREV}}-cms-db-password:
external: true
""";
} }
/// <summary>Context object with all inputs needed to render a Compose file.</summary> /// <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"; public string PhpMaxExecutionTime { get; set; } = "600";
// Pangolin / Newt // Pangolin / Newt
public bool IncludeNewt { get; set; } = true;
public string PangolinEndpoint { get; set; } = "https://app.pangolin.net"; public string PangolinEndpoint { get; set; } = "https://app.pangolin.net";
public string? NewtId { get; set; } public string? NewtId { get; set; }
public string? NewtSecret { get; set; } public string? NewtSecret { get; set; }
// CIFS volume settings // CIFS volume settings
public bool UseCifsVolumes { get; set; }
public string? CifsServer { 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? CifsUsername { get; set; }
public string? CifsPassword { get; set; } public string? CifsPassword { get; set; }
public string? CifsExtraOptions { 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 yamlPath = FindFile(cacheDir, "template.yml");
var envPath = FindFile(cacheDir, "template.env");
if (yamlPath == null) if (yamlPath == null)
throw new FileNotFoundException("template.yml 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.");
if (envPath == null)
throw new FileNotFoundException("template.env not found in repository root.");
var yaml = await File.ReadAllTextAsync(yamlPath); var yaml = await File.ReadAllTextAsync(yamlPath);
var envLines = (await File.ReadAllLinesAsync(envPath))
.Where(l => !string.IsNullOrWhiteSpace(l) && !l.TrimStart().StartsWith('#'))
.ToList();
return new TemplateConfig return new TemplateConfig
{ {
Yaml = yaml, Yaml = yaml,
EnvLines = envLines,
FetchedAt = DateTime.UtcNow FetchedAt = DateTime.UtcNow
}; };
} }

View File

@@ -12,6 +12,30 @@ public interface IDockerCliService
Task<DeploymentResultDto> RemoveStackAsync(string stackName); Task<DeploymentResultDto> RemoveStackAsync(string stackName);
Task<List<StackInfo>> ListStacksAsync(); Task<List<StackInfo>> ListStacksAsync();
Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName); 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 public class StackInfo

View File

@@ -4,6 +4,7 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MySqlConnector;
using OTSSignsOrchestrator.Core.Configuration; using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Data; using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.DTOs;
@@ -72,21 +73,33 @@ public class InstanceService
{ {
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName); _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() var existing = await _db.CmsInstances.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null); .FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
if (existing != 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 repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat); var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat);
if (!string.IsNullOrWhiteSpace(repoUrl)) if (string.IsNullOrWhiteSpace(repoUrl))
{ throw new InvalidOperationException("Git template repository URL is not configured. Set it in Settings → Git Repo URL.");
_logger.LogInformation("Cloning template repo: {RepoUrl}", repoUrl);
await _git.FetchAsync(repoUrl, repoPat); _logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl);
} var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
// ── 2. Generate MySQL password → Docker Swarm secret ──────────── // ── 2. Generate MySQL password → Docker Swarm secret ────────────
var mysqlPassword = GenerateRandomPassword(32); var mysqlPassword = GenerateRandomPassword(32);
@@ -116,7 +129,8 @@ public class InstanceService
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"); var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
var cifsServer = dto.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer); 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 cifsUsername = dto.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword); var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
var cifsOptions = dto.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777"); 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 phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// ── 4. Render compose YAML ────────────────────────────────────── // ── 4. Render compose YAML from template ────────────────────────
var renderCtx = new RenderContext var renderCtx = new RenderContext
{ {
CustomerName = dto.CustomerName, CustomerName = dto.CustomerName,
@@ -158,20 +172,21 @@ public class InstanceService
PhpPostMaxSize = phpPostMaxSize, PhpPostMaxSize = phpPostMaxSize,
PhpUploadMaxFilesize = phpUploadMaxFilesize, PhpUploadMaxFilesize = phpUploadMaxFilesize,
PhpMaxExecutionTime = phpMaxExecutionTime, PhpMaxExecutionTime = phpMaxExecutionTime,
IncludeNewt = true,
PangolinEndpoint = pangolinEndpoint, PangolinEndpoint = pangolinEndpoint,
NewtId = dto.NewtId, NewtId = dto.NewtId,
NewtSecret = dto.NewtSecret, NewtSecret = dto.NewtSecret,
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
CifsServer = cifsServer, CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath, CifsShareName = cifsShareName,
CifsShareFolder = cifsShareFolder,
CifsUsername = cifsUsername, CifsUsername = cifsUsername,
CifsPassword = cifsPassword, CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions, 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) if (_dockerOptions.ValidateBeforeDeploy)
{ {
@@ -180,12 +195,35 @@ public class InstanceService
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}"); 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); var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
if (!deployResult.Success) if (!deployResult.Success)
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}"); throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
// ── 6. Record instance ────────────────────────────────────────── // ── 8. Record instance ──────────────────────────────────────────
var instance = new CmsInstance var instance = new CmsInstance
{ {
CustomerName = dto.CustomerName, CustomerName = dto.CustomerName,
@@ -202,7 +240,8 @@ public class InstanceService
Status = InstanceStatus.Active, Status = InstanceStatus.Active,
SshHostId = dto.SshHostId, SshHostId = dto.SshHostId,
CifsServer = cifsServer, CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath, CifsShareName = cifsShareName,
CifsShareFolder = cifsShareFolder,
CifsUsername = cifsUsername, CifsUsername = cifsUsername,
CifsPassword = cifsPassword, CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions, CifsExtraOptions = cifsOptions,
@@ -220,7 +259,7 @@ public class InstanceService
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms", _logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
stackName, instance.Id, sw.ElapsedMilliseconds); stackName, instance.Id, sw.ElapsedMilliseconds);
deployResult.ServiceCount = renderCtx.IncludeNewt ? 4 : 3; deployResult.ServiceCount = 4;
deployResult.Message = "Instance deployed successfully."; deployResult.Message = "Instance deployed successfully.";
return deployResult; return deployResult;
} }
@@ -238,13 +277,13 @@ public class InstanceService
} }
/// <summary> /// <summary>
/// Creates MySQL database and user on external MySQL server via SSH. /// Creates MySQL database and user on the external MySQL server using a direct TCP connection.
/// Called by the ViewModel before CreateInstanceAsync since it needs SSH access. /// Admin credentials are sourced from SettingsService (MySql.AdminUser / MySql.AdminPassword).
/// The new user's password is passed in and never logged.
/// </summary> /// </summary>
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync( public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
string abbrev, string abbrev,
string mysqlPassword, string mysqlPassword)
Func<string, Task<(int ExitCode, string Stdout, string Stderr)>> runSshCommand)
{ {
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); 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 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 userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
var safePwd = mySqlAdminPassword.Replace("'", "'\\''"); _logger.LogInformation("Creating MySQL database {Db} and user {User} via direct TCP", dbName, userName);
var safeUserPwd = mysqlPassword.Replace("'", "'\\''");
var sql = $"CREATE DATABASE IF NOT EXISTS \\`{dbName}\\`; " if (!int.TryParse(mySqlPort, out var port))
+ $"CREATE USER IF NOT EXISTS '{userName}'@'%' IDENTIFIED BY '{safeUserPwd}'; " port = 3306;
+ $"GRANT ALL PRIVILEGES ON \\`{dbName}\\`.* TO '{userName}'@'%'; "
+ $"FLUSH PRIVILEGES;";
var cmd = $"mysql -h {mySqlHost} -P {mySqlPort} -u {mySqlAdminUser} -p'{safePwd}' -e \"{sql}\" 2>&1"; var csb = new MySqlConnectionStringBuilder
_logger.LogInformation("Creating MySQL database {Db} and user {User}", dbName, userName);
var (exitCode, stdout, stderr) = await runSshCommand(cmd);
if (exitCode == 0)
{ {
_logger.LogInformation("MySQL database and user created: {Db} / {User}", dbName, userName); Server = mySqlHost,
return (true, $"Database '{dbName}' and user '{userName}' created."); 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();
} }
var error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr; await using (var cmd = connection.CreateCommand())
_logger.LogError("MySQL setup failed: {Error}", error); {
return (false, $"MySQL setup failed: {error.Trim()}"); 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.");
}
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) 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.XiboUsername != null) instance.XiboUsername = dto.XiboUsername;
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword; if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
if (dto.CifsServer != null) instance.CifsServer = dto.CifsServer; 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.CifsUsername != null) instance.CifsUsername = dto.CifsUsername;
if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword; if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword;
if (dto.CifsExtraOptions != null) instance.CifsExtraOptions = dto.CifsExtraOptions; 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"); var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
// Use per-instance CIFS credentials // Use per-instance CIFS credentials, falling back to global settings
var cifsServer = instance.CifsServer; var cifsServer = instance.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
var cifsShareBasePath = instance.CifsShareBasePath; var cifsShareName = instance.CifsShareName ?? await _settings.GetAsync(SettingsService.CifsShareName);
var cifsUsername = instance.CifsUsername; var cifsShareFolder = instance.CifsShareFolder ?? await _settings.GetAsync(SettingsService.CifsShareFolder);
var cifsPassword = instance.CifsPassword; var cifsUsername = instance.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
var cifsOptions = instance.CifsExtraOptions ?? "file_mode=0777,dir_mode=0777"; 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 cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"); 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 phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); 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 var renderCtx = new RenderContext
{ {
CustomerName = instance.CustomerName, CustomerName = instance.CustomerName,
@@ -367,18 +455,19 @@ public class InstanceService
PhpPostMaxSize = phpPostMaxSize, PhpPostMaxSize = phpPostMaxSize,
PhpUploadMaxFilesize = phpUploadMaxFilesize, PhpUploadMaxFilesize = phpUploadMaxFilesize,
PhpMaxExecutionTime = phpMaxExecutionTime, PhpMaxExecutionTime = phpMaxExecutionTime,
IncludeNewt = true,
PangolinEndpoint = pangolinEndpoint, PangolinEndpoint = pangolinEndpoint,
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
CifsServer = cifsServer, CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath, CifsShareName = cifsShareName,
CifsShareFolder = cifsShareFolder,
CifsUsername = cifsUsername, CifsUsername = cifsUsername,
CifsPassword = cifsPassword, CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions, 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) if (_dockerOptions.ValidateBeforeDeploy)
{ {
@@ -387,6 +476,30 @@ public class InstanceService
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}"); 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); var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true);
if (!deployResult.Success) if (!deployResult.Success)
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}"); throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");

View File

@@ -51,7 +51,8 @@ public class SettingsService
// CIFS // CIFS
public const string CifsServer = "Cifs.Server"; 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 CifsUsername = "Cifs.Username";
public const string CifsPassword = "Cifs.Password"; public const string CifsPassword = "Cifs.Password";
public const string CifsOptions = "Cifs.Options"; public const string CifsOptions = "Cifs.Options";

View File

@@ -118,11 +118,11 @@ public class App : Application
// SSH services (singletons — maintain connections) // SSH services (singletons — maintain connections)
services.AddSingleton<SshConnectionService>(); services.AddSingleton<SshConnectionService>();
// Docker services via SSH (scoped so they get fresh per-operation context) // Docker services via SSH (singletons — SetHost() must persist across scopes)
services.AddTransient<SshDockerCliService>(); services.AddSingleton<SshDockerCliService>();
services.AddTransient<SshDockerSecretsService>(); services.AddSingleton<SshDockerSecretsService>();
services.AddTransient<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>()); services.AddSingleton<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
services.AddTransient<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>()); services.AddSingleton<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
// Core services // Core services
services.AddTransient<SettingsService>(); services.AddTransient<SettingsService>();

View File

@@ -25,6 +25,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
<PackageReference Include="MySqlConnector" Version="2.5.0" />
<PackageReference Include="Serilog" Version="4.2.0" /> <PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" /> <PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />

View File

@@ -151,9 +151,145 @@ public class SshDockerCliService : IDockerCliService
.ToList(); .ToList();
} }
public async Task<bool> EnsureDirectoryAsync(string path)
{
EnsureHost();
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"mkdir -p {path}");
if (exitCode != 0)
_logger.LogWarning("Failed to create directory {Path} on {Host}: {Error}", path, _currentHost!.Label, stderr);
else
_logger.LogInformation("Ensured directory exists on {Host}: {Path}", _currentHost!.Label, path);
return exitCode == 0;
}
public async Task<bool> EnsureSmbFoldersAsync(
string cifsServer,
string cifsShareName,
string cifsUsername,
string cifsPassword,
IEnumerable<string> folderNames,
string? cifsShareFolder = null)
{
EnsureHost();
var allSucceeded = true;
var subFolder = (cifsShareFolder ?? string.Empty).Trim('/');
// If a subfolder is specified, ensure it exists first
if (!string.IsNullOrEmpty(subFolder))
{
var mkdirCmd = $"smbclient //{cifsServer}/{cifsShareName} -U '{cifsUsername}%{cifsPassword}' -c 'mkdir {subFolder}' 2>&1";
var (_, mkdirOut, _) = await _ssh.RunCommandAsync(_currentHost!, mkdirCmd);
var mkdirOutput = mkdirOut ?? string.Empty;
var alreadyExists = mkdirOutput.Contains("NT_STATUS_OBJECT_NAME_COLLISION", StringComparison.OrdinalIgnoreCase)
|| mkdirOutput.Contains("already exists", StringComparison.OrdinalIgnoreCase);
var success = alreadyExists || !mkdirOutput.Contains("NT_STATUS_", StringComparison.OrdinalIgnoreCase);
if (success)
_logger.LogInformation("SMB subfolder ensured: //{Server}/{Share}/{Folder}", cifsServer, cifsShareName, subFolder);
else
{
_logger.LogWarning("Failed to create SMB subfolder //{Server}/{Share}/{Folder}: {Output}",
cifsServer, cifsShareName, subFolder, mkdirOutput.Trim());
allSucceeded = false;
}
}
// Build the target path prefix for volume folders
var pathPrefix = string.IsNullOrEmpty(subFolder) ? string.Empty : $"{subFolder}/";
foreach (var folder in folderNames)
{
var targetFolder = $"{pathPrefix}{folder}";
// Run smbclient on the remote Docker host to create the folder on the share.
// NT_STATUS_OBJECT_NAME_COLLISION means it already exists — treat as success.
var cmd = $"smbclient //{cifsServer}/{cifsShareName} -U '{cifsUsername}%{cifsPassword}' -c 'mkdir {targetFolder}' 2>&1";
var (_, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd);
var output = stdout ?? string.Empty;
var exists = output.Contains("NT_STATUS_OBJECT_NAME_COLLISION", StringComparison.OrdinalIgnoreCase)
|| output.Contains("already exists", StringComparison.OrdinalIgnoreCase);
var ok = exists || !output.Contains("NT_STATUS_", StringComparison.OrdinalIgnoreCase);
if (ok)
_logger.LogInformation("SMB folder ensured: //{Server}/{Share}/{Folder}", cifsServer, cifsShareName, targetFolder);
else
{
_logger.LogWarning("Failed to create SMB folder //{Server}/{Share}/{Folder}: {Output}",
cifsServer, cifsShareName, targetFolder, output.Trim());
allSucceeded = false;
}
}
return allSucceeded;
}
private void EnsureHost() private void EnsureHost()
{ {
if (_currentHost == null) if (_currentHost == null)
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands."); throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
} }
public async Task<bool> RemoveStackVolumesAsync(string stackName)
{
EnsureHost();
// ── 1. Remove the stack first so containers release the volumes ─────
_logger.LogInformation("Removing stack {StackName} before volume cleanup", stackName);
var (rmExit, _, rmErr) = await _ssh.RunCommandAsync(_currentHost!,
$"docker stack rm {stackName} 2>&1 || true");
if (rmExit != 0)
_logger.LogWarning("Stack rm returned non-zero for {StackName}: {Err}", stackName, rmErr);
// Give Swarm a moment to tear down containers on all nodes
await Task.Delay(5000);
// ── 2. Clean volumes on the local (manager) node ────────────────────
var localCmd = $"docker volume ls --filter \"name={stackName}_\" -q | xargs -r docker volume rm 2>&1 || true";
var (_, localOut, _) = await _ssh.RunCommandAsync(_currentHost!, localCmd);
if (!string.IsNullOrEmpty(localOut?.Trim()))
_logger.LogInformation("Volume cleanup (manager): {Output}", localOut!.Trim());
// ── 3. Clean volumes on ALL swarm nodes via a temporary global service ──
// This deploys a short-lived container on every node that mounts the Docker
// socket and removes matching volumes. This handles worker nodes that the
// orchestrator has no direct SSH access to.
var cleanupSvcName = $"vol-cleanup-{stackName}".Replace("_", "-");
// Remove leftover cleanup service from a previous run (if any)
await _ssh.RunCommandAsync(_currentHost!,
$"docker service rm {cleanupSvcName} 2>/dev/null || true");
var createCmd = string.Join(" ",
"docker service create",
"--detach",
"--mode global",
"--restart-condition none",
$"--name {cleanupSvcName}",
"--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock",
"docker:cli",
"sh", "-c",
$"'docker volume ls -q --filter name={stackName}_ | xargs -r docker volume rm 2>&1; echo done'");
_logger.LogInformation("Deploying global volume-cleanup service on all swarm nodes for {StackName}", stackName);
var (svcExit, svcOut, svcErr) = await _ssh.RunCommandAsync(_currentHost!, createCmd);
if (svcExit != 0)
{
_logger.LogWarning("Global volume cleanup service creation failed: {Err}", svcErr);
}
else
{
// Wait for the cleanup tasks to finish on all nodes
_logger.LogInformation("Waiting for volume cleanup tasks to complete on all nodes...");
await Task.Delay(10000);
}
// Remove the cleanup service
await _ssh.RunCommandAsync(_currentHost!,
$"docker service rm {cleanupSvcName} 2>/dev/null || true");
_logger.LogInformation("Volume cleanup complete for stack {StackName}", stackName);
return true;
}
} }

View File

@@ -37,7 +37,8 @@ public partial class CreateInstanceViewModel : ObservableObject
// CIFS / SMB credentials (per-instance, defaults loaded from global settings) // CIFS / SMB credentials (per-instance, defaults loaded from global settings)
[ObservableProperty] private string _cifsServer = string.Empty; [ObservableProperty] private string _cifsServer = string.Empty;
[ObservableProperty] private string _cifsShareBasePath = string.Empty; [ObservableProperty] private string _cifsShareName = string.Empty;
[ObservableProperty] private string _cifsShareFolder = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty; [ObservableProperty] private string _cifsUsername = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty; [ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsExtraOptions = string.Empty; [ObservableProperty] private string _cifsExtraOptions = string.Empty;
@@ -110,7 +111,8 @@ public partial class CreateInstanceViewModel : ObservableObject
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>(); var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty; CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
CifsShareBasePath = await settings.GetAsync(SettingsService.CifsShareBasePath) ?? string.Empty; CifsShareName = await settings.GetAsync(SettingsService.CifsShareName) ?? string.Empty;
CifsShareFolder = await settings.GetAsync(SettingsService.CifsShareFolder) ?? string.Empty;
CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty; CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty;
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty; CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777"); CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
@@ -148,7 +150,6 @@ public partial class CreateInstanceViewModel : ObservableObject
dockerCli.SetHost(SelectedSshHost); dockerCli.SetHost(SelectedSshHost);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>(); var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(SelectedSshHost); dockerSecrets.SetHost(SelectedSshHost);
var ssh = _services.GetRequiredService<SshConnectionService>();
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>(); var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
@@ -161,12 +162,11 @@ public partial class CreateInstanceViewModel : ObservableObject
SetProgress(20, "Generating secrets..."); SetProgress(20, "Generating secrets...");
var mysqlPassword = GenerateRandomPassword(32); var mysqlPassword = GenerateRandomPassword(32);
// ── Step 3: Create MySQL database + user via SSH ─────────────── // ── Step 3: Create MySQL database + user via direct TCP ────────
SetProgress(35, "Creating MySQL database and user..."); SetProgress(35, "Creating MySQL database and user...");
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync( var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
Abbrev, Abbrev,
mysqlPassword, mysqlPassword);
cmd => ssh.RunCommandAsync(SelectedSshHost, cmd));
AppendOutput($"[MySQL] {mysqlMsg}"); AppendOutput($"[MySQL] {mysqlMsg}");
if (!mysqlOk) if (!mysqlOk)
@@ -195,7 +195,8 @@ public partial class CreateInstanceViewModel : ObservableObject
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(), NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(), NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(), CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
CifsShareBasePath = string.IsNullOrWhiteSpace(CifsShareBasePath) ? null : CifsShareBasePath.Trim(), CifsShareName = string.IsNullOrWhiteSpace(CifsShareName) ? null : CifsShareName.Trim(),
CifsShareFolder = string.IsNullOrWhiteSpace(CifsShareFolder) ? null : CifsShareFolder.Trim(),
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(), CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(), CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(), CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using MySqlConnector;
using OTSSignsOrchestrator.Core.Services; using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels; namespace OTSSignsOrchestrator.Desktop.ViewModels;
@@ -42,7 +43,8 @@ public partial class SettingsViewModel : ObservableObject
// ── CIFS ──────────────────────────────────────────────────────────────── // ── CIFS ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _cifsServer = string.Empty; [ObservableProperty] private string _cifsServer = string.Empty;
[ObservableProperty] private string _cifsShareBasePath = string.Empty; [ObservableProperty] private string _cifsShareName = string.Empty;
[ObservableProperty] private string _cifsShareFolder = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty; [ObservableProperty] private string _cifsUsername = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty; [ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777"; [ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
@@ -100,7 +102,8 @@ public partial class SettingsViewModel : ObservableObject
// CIFS // CIFS
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty); CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty);
CifsShareBasePath = await svc.GetAsync(SettingsService.CifsShareBasePath, string.Empty); CifsShareName = await svc.GetAsync(SettingsService.CifsShareName, string.Empty);
CifsShareFolder = await svc.GetAsync(SettingsService.CifsShareFolder, string.Empty);
CifsUsername = await svc.GetAsync(SettingsService.CifsUsername, string.Empty); CifsUsername = await svc.GetAsync(SettingsService.CifsUsername, string.Empty);
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty); CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777"); CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
@@ -166,7 +169,8 @@ public partial class SettingsViewModel : ObservableObject
// CIFS // CIFS
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false), (SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false),
(SettingsService.CifsShareBasePath, NullIfEmpty(CifsShareBasePath), SettingsService.CatCifs, false), (SettingsService.CifsShareName, NullIfEmpty(CifsShareName), SettingsService.CatCifs, false),
(SettingsService.CifsShareFolder, NullIfEmpty(CifsShareFolder), SettingsService.CatCifs, false),
(SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false), (SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false),
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true), (SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false), (SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
@@ -208,30 +212,34 @@ public partial class SettingsViewModel : ObservableObject
} }
IsBusy = true; IsBusy = true;
StatusMessage = "Testing MySQL connection via SSH..."; StatusMessage = "Testing MySQL connection...";
try try
{ {
// The test runs a mysql --version or a simple SELECT 1 query via SSH if (!int.TryParse(MySqlPort, out var port))
// We need an SshHost to route through — use the first available port = 3306;
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OTSSignsOrchestrator.Core.Data.XiboContext>();
var host = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
.FirstOrDefaultAsync(db.SshHosts);
if (host == null) var csb = new MySqlConnectionStringBuilder
{ {
StatusMessage = "No SSH hosts configured. Add one in the Hosts page first."; Server = MySqlHost,
return; Port = (uint)port,
UserID = MySqlAdminUser,
Password = MySqlAdminPassword,
ConnectionTimeout = 10,
SslMode = MySqlSslMode.Preferred,
};
await using var connection = new MySqlConnection(csb.ConnectionString);
await connection.OpenAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT 1";
await cmd.ExecuteScalarAsync();
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port}).";
} }
catch (MySqlException ex)
var ssh = _services.GetRequiredService<Services.SshConnectionService>(); {
var port = int.TryParse(MySqlPort, out var p) ? p : 3306; StatusMessage = $"MySQL connection failed: {ex.Message}";
var cmd = $"mysql -h {MySqlHost} -P {port} -u {MySqlAdminUser} -p'{MySqlAdminPassword.Replace("'", "'\\''")}' -e 'SELECT 1' 2>&1";
var (exitCode, stdout, stderr) = await ssh.RunCommandAsync(host, cmd);
StatusMessage = exitCode == 0
? $"MySQL connection successful via {host.Label}."
: $"MySQL connection failed: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}";
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -54,8 +54,11 @@
<TextBlock Text="CIFS Server" FontSize="12" /> <TextBlock Text="CIFS Server" FontSize="12" />
<TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" /> <TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" />
<TextBlock Text="Share Base Path" FontSize="12" /> <TextBlock Text="Share Name" FontSize="12" />
<TextBox Text="{Binding CifsShareBasePath}" Watermark="e.g. /share/cms" /> <TextBox Text="{Binding CifsShareName}" Watermark="e.g. u548897-sub1" />
<TextBlock Text="Share Folder (optional)" FontSize="12" />
<TextBox Text="{Binding CifsShareFolder}" Watermark="e.g. ots_cms (leave empty for share root)" />
<TextBlock Text="Username" FontSize="12" /> <TextBlock Text="Username" FontSize="12" />
<TextBox Text="{Binding CifsUsername}" Watermark="SMB username" /> <TextBox Text="{Binding CifsUsername}" Watermark="SMB username" />

View File

@@ -127,8 +127,11 @@
<TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" /> <TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" />
<TextBox Text="{Binding CifsServer}" Watermark="nas.local" /> <TextBox Text="{Binding CifsServer}" Watermark="nas.local" />
<TextBlock Text="Share Base Path" FontSize="12" /> <TextBlock Text="Share Name" FontSize="12" />
<TextBox Text="{Binding CifsShareBasePath}" Watermark="/share/cms" /> <TextBox Text="{Binding CifsShareName}" Watermark="u548897-sub1" />
<TextBlock Text="Share Folder (optional)" FontSize="12" />
<TextBox Text="{Binding CifsShareFolder}" Watermark="ots_cms (leave empty for share root)" />
<TextBlock Text="Username" FontSize="12" /> <TextBlock Text="Username" FontSize="12" />
<TextBox Text="{Binding CifsUsername}" Watermark="smbuser" /> <TextBox Text="{Binding CifsUsername}" Watermark="smbuser" />

View File

@@ -1,459 +0,0 @@
@page "/instances/create"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
@inject InstanceService InstanceSvc
@inject NavigationManager Navigation
@inject IOptions<InstanceDefaultsOptions> Defaults
@using Microsoft.Extensions.Options
@using OTSSignsOrchestrator.Configuration
<PageTitle>Create Instance - OTS Signs Orchestrator</PageTitle>
<h4 class="mb-4">New CMS Instance</h4>
<div class="row g-4">
@* ── Left column: form ── *@
<div class="col-lg-5">
<EditForm Model="model" OnValidSubmit="HandleSubmit" FormName="createInstance">
<DataAnnotationsValidator />
<ValidationSummary class="alert alert-danger py-2 small" />
<fieldset disabled="@deploying">
@* ── Customer ── *@
<div class="mb-3">
<label class="form-label fw-semibold">Customer Name</label>
<InputText @bind-Value="model.CustomerName" class="form-control"
placeholder="Acme Corporation"
@oninput="OnNameInput" />
<div class="form-text">Display name — stored as a comment in the stack file.</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Abbreviation <span class="text-muted fw-normal">(3 letters)</span></label>
<InputText @bind-Value="model.CustomerAbbrev" class="form-control font-monospace text-uppercase"
placeholder="ACM" maxlength="3"
style="text-transform:uppercase; letter-spacing:.15em; width:6rem;"
@oninput="OnAbbrevInput" />
<div class="form-text">3 letters used as a prefix for every stack resource.</div>
</div>
@* ── Optional overrides ── *@
<details class="mb-3">
<summary class="text-muted small" style="cursor:pointer;">Advanced overrides</summary>
<div class="mt-2 ps-2 border-start">
<div class="mb-2">
<label class="form-label small">Template Repo URL <span class="text-muted">(leave blank to use default)</span></label>
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control form-control-sm"
placeholder="@Defaults.Value.TemplateRepoUrl" />
</div>
<div class="mb-2">
<label class="form-label small">Placement Constraints <span class="text-muted">(comma-separated)</span></label>
<InputText @bind-Value="constraintsText" class="form-control form-control-sm"
placeholder="node.labels.xibo==true" />
</div>
<div class="row">
<div class="col mb-2">
<label class="form-label small">Xibo Client ID</label>
<InputText @bind-Value="model.XiboUsername" class="form-control form-control-sm" />
</div>
<div class="col mb-2">
<label class="form-label small">Xibo Client Secret</label>
<InputText @bind-Value="model.XiboPassword" class="form-control form-control-sm" type="password" />
</div>
</div>
</div>
</details>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-success px-4" disabled="@deploying">
@if (deploying)
{
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
<span>Deploying…</span>
}
else
{
<span>Deploy</span>
}
</button>
<a href="/" class="btn btn-outline-secondary">Cancel</a>
</div>
</fieldset>
</EditForm>
@if (!string.IsNullOrEmpty(resultMessage))
{
<div class="alert @(resultSuccess ? "alert-success" : "alert-danger") mt-3">
@resultMessage
@if (resultSuccess && createdInstanceId.HasValue)
{
<a href="instances/@createdInstanceId" class="alert-link ms-2">View Instance →</a>
}
</div>
}
</div>
@* ── Right column: live preview ── *@
<div class="col-lg-7">
<div class="card h-100 border-0 bg-body-tertiary">
<div class="card-header border-0 bg-body-tertiary d-flex align-items-center gap-2">
<span class="fw-semibold">Resource Preview</span>
@if (AbbrevIsValid)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">@Abbrev</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary border">enter abbreviation</span>
}
</div>
<div class="card-body pt-2">
@* Stack *@
<PreviewRow Label="Stack name" Value="@Abbrev" Icon="layers" />
<PreviewRow Label="Overlay network" Value="@($"{Abbrev}-net")" Icon="share" />
<hr class="my-2" />
@* Services *@
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Services</div>
<PreviewRow Label="Web (Xibo CMS)" Value="@($"{Abbrev}-web")" />
<PreviewRow Label="Memcached" Value="@($"{Abbrev}-memcached")" />
<PreviewRow Label="QuickChart" Value="@($"{Abbrev}-quickchart")" />
<PreviewRow Label="Newt (tunnel)" Value="@($"{Abbrev}-newt")" Muted="true" Note="(if template provides NEWT_ID)" />
<hr class="my-2" />
@* Volumes *@
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">CIFS Volumes</div>
@foreach (var vol in new[] { "cms-custom", "cms-backup", "cms-library", "cms-userscripts", "cms-ca-certs" })
{
<PreviewRow Label="@vol" Value="@($"{Abbrev}-{vol}")" />
}
<PreviewRow Label="db-data" Value="@($"{Abbrev}-db-data")" Note="(local driver)" Muted="true" />
<hr class="my-2" />
@* Docker secret *@
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Docker Secret</div>
<PreviewRow Label="MySQL password" Value="@($"{Abbrev}_mysql_password")" Icon="key" />
<hr class="my-2" />
@* External config *@
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">External Resources</div>
<PreviewRow Label="CMS URL" Value="@CmsServer" Icon="globe" />
<PreviewRow Label="MySQL database" Value="@MySqlDb" />
<PreviewRow Label="MySQL user" Value="@MySqlUser" />
<PreviewRow Label="Theme host path" Value="@ThemePath" />
@if (!string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl))
{
<hr class="my-2" />
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Template</div>
<PreviewRow Label="Repo" Value="@Defaults.Value.TemplateRepoUrl" Icon="git-branch" />
}
else
{
<div class="alert alert-warning py-1 px-2 small mt-2 mb-0">
⚠ No template repo configured. <a href="/settings">Set it in Settings</a> before deploying.
</div>
}
</div>
</div>
</div>
</div>
@* Inline sub-component for a labelled preview row *@
@code {
// ── State ────────────────────────────────────────────────────────────────
private CreateInstanceDto model = new();
private string? constraintsText;
private bool deploying;
private string? resultMessage;
private bool resultSuccess;
private Guid? createdInstanceId;
// ── Derived / preview ────────────────────────────────────────────────────
private string Abbrev =>
string.IsNullOrWhiteSpace(model.CustomerAbbrev)
? "???"
: model.CustomerAbbrev.ToLowerInvariant()[..Math.Min(3, model.CustomerAbbrev.Length)];
private bool AbbrevIsValid =>
!string.IsNullOrWhiteSpace(model.CustomerAbbrev) && model.CustomerAbbrev.Length == 3;
private string Apply(string template) => template.Replace("{abbrev}", Abbrev);
private string CmsServer => Apply(Defaults.Value.CmsServerNameTemplate);
private string MySqlDb => Apply(Defaults.Value.MySqlDatabaseTemplate);
private string MySqlUser => Apply(Defaults.Value.MySqlUserTemplate);
private string ThemePath => string.IsNullOrWhiteSpace(model.ThemeHostPath)
? Defaults.Value.ThemeHostPath
: model.ThemeHostPath;
// ── Events ───────────────────────────────────────────────────────────────
private void OnNameInput(ChangeEventArgs e)
{
// Auto-suggest abbreviation from first word if user hasn't typed one yet
if (string.IsNullOrWhiteSpace(model.CustomerAbbrev))
{
var word = (e.Value?.ToString() ?? "")
.Split([' ', '-', '_'], StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault(w => w.Length > 0);
if (!string.IsNullOrEmpty(word))
model.CustomerAbbrev = word[..Math.Min(3, word.Length)].ToUpperInvariant();
}
}
private void OnAbbrevInput(ChangeEventArgs e)
{
// Enforce uppercase in the bound value immediately
model.CustomerAbbrev = (e.Value?.ToString() ?? "").ToUpperInvariant();
}
// ── Submit ───────────────────────────────────────────────────────────────
private async Task HandleSubmit()
{
deploying = true;
resultMessage = null;
model.CustomerAbbrev = model.CustomerAbbrev?.ToUpperInvariant() ?? string.Empty;
try
{
if (!string.IsNullOrWhiteSpace(constraintsText))
{
model.Constraints = constraintsText
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(c => c.Trim())
.Where(c => !string.IsNullOrEmpty(c))
.ToList();
}
var result = await InstanceSvc.CreateInstanceAsync(model);
resultSuccess = result.Success;
resultMessage = result.Success
? $"Instance '{result.StackName}' deployed in {result.DurationMs}ms ({result.ServiceCount} services)."
: $"Deployment failed: {result.ErrorMessage}";
if (result.Success)
{
var page = await InstanceSvc.ListInstancesAsync(1, 1, Abbrev);
createdInstanceId = page.Items.FirstOrDefault()?.Id;
}
}
catch (Exception ex)
{
resultSuccess = false;
resultMessage = $"Error: {ex.Message}";
}
finally
{
deploying = false;
}
}
// ── Inline sub-component: preview row ────────────────────────────────────
private RenderFragment PreviewRow(string Label, string Value, string? Icon = null, string? Note = null, bool Muted = false) =>
@<div class="d-flex align-items-baseline gap-2 mb-1 small">
<span class="text-muted" style="min-width:9rem;">@Label</span>
<code class="@(Muted ? "text-secondary" : "text-body") flex-fill" style="font-size:.875em;">@Value</code>
@if (!string.IsNullOrEmpty(Note))
{
<span class="text-muted fst-italic" style="font-size:.8em;">@Note</span>
}
</div>;
}
<PageTitle>Create Instance - OTS Signs Orchestrator</PageTitle>
<h3>Create CMS Instance</h3>
<div class="row">
<div class="col-lg-6">
<EditForm Model="model" OnValidSubmit="HandleSubmit" FormName="createInstance">
<DataAnnotationsValidator />
<ValidationSummary class="text-danger" />
<fieldset disabled="@deploying">
<div class="card mb-3">
<div class="card-header">Customer Details</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-semibold">Customer Name</label>
<InputText @bind-Value="model.CustomerName" class="form-control"
placeholder="Acme Corporation"
@oninput="OnCustomerNameInput" />
<div class="form-text">Full display name — stored as a comment in the stack file.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">3-Letter Abbreviation</label>
<div class="input-group">
<InputText @bind-Value="model.CustomerAbbrev" class="form-control text-uppercase"
placeholder="ACM" maxlength="3" style="text-transform:uppercase;" />
<span class="input-group-text text-muted" style="font-size:0.85em;">
Stack: <strong class="ms-1">@(model.CustomerAbbrev?.ToLowerInvariant() ?? "…")</strong>
</span>
</div>
<div class="form-text">
Exactly 3 letters (az). Used as the prefix for all stack resources:
services, volumes, and network names.
</div>
</div>
</div>
</div>
@* Show what will be auto-configured from settings *@
<div class="card mb-3 border-secondary">
<div class="card-header bg-light text-muted">Auto-configured from Settings</div>
<div class="card-body text-muted small">
<div class="row">
<div class="col-6 mb-1"><strong>CMS server:</strong><br />@(Defaults.Value.CmsServerNameTemplate.Replace("{abbrev}", AbbrevPreview))</div>
<div class="col-6 mb-1"><strong>Theme path:</strong><br />@Defaults.Value.ThemeHostPath</div>
<div class="col-6 mb-1"><strong>MySQL DB:</strong><br />@(Defaults.Value.MySqlDatabaseTemplate.Replace("{abbrev}", AbbrevPreview))</div>
<div class="col-6 mb-1"><strong>MySQL user:</strong><br />@(Defaults.Value.MySqlUserTemplate.Replace("{abbrev}", AbbrevPreview))</div>
<div class="col-6 mb-1"><strong>SMTP server:</strong><br />@Defaults.Value.SmtpServer</div>
<div class="col-6 mb-1"><strong>Template repo:</strong><br />@(string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl) ? "⚠️ Not configured" : Defaults.Value.TemplateRepoUrl)</div>
</div>
<a href="/settings" class="small">Edit defaults in Settings</a>
</div>
</div>
@* Optional overrides *@
<div class="card mb-3">
<div class="card-header">Optional Overrides</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Template Repo URL <span class="text-muted">(overrides setting)</span></label>
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control"
placeholder="Leave blank to use configured default" />
</div>
<div class="mb-3">
<label class="form-label">Theme Host Path <span class="text-muted">(overrides setting default: /cms/ots-theme)</span></label>
<InputText @bind-Value="model.ThemeHostPath" class="form-control"
placeholder="/cms/ots-theme" />
</div>
<div class="mb-3">
<label class="form-label">Placement Constraints <span class="text-muted">(comma-separated)</span></label>
<InputText @bind-Value="constraintsText" class="form-control"
placeholder="node.labels.xibo==true, node.role==manager" />
</div>
</div>
</div>
@* Xibo Credentials (optional) *@
<div class="card mb-3">
<div class="card-header">Xibo API Credentials <span class="text-muted fw-normal">(optional)</span></div>
<div class="card-body">
<p class="text-muted small">Provide credentials to enable API connectivity testing after deploy.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Client ID</label>
<InputText @bind-Value="model.XiboUsername" class="form-control" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Client Secret</label>
<InputText @bind-Value="model.XiboPassword" class="form-control" type="password" />
</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success" disabled="@deploying">
@(deploying ? "Deploying…" : "Deploy Instance")
</button>
<a href="/" class="btn btn-secondary">Cancel</a>
</div>
</fieldset>
</EditForm>
@if (!string.IsNullOrEmpty(resultMessage))
{
<div class="alert @(resultSuccess ? "alert-success" : "alert-danger") mt-3">
@resultMessage
@if (resultSuccess && createdInstanceId.HasValue)
{
<a href="instances/@createdInstanceId" class="alert-link ms-2">View Details</a>
}
</div>
}
</div>
</div>
@code {
private CreateInstanceDto model = new();
private string? constraintsText;
private bool deploying;
private string? resultMessage;
private bool resultSuccess;
private Guid? createdInstanceId;
/// <summary>Live abbrev preview (lowercase, 3 chars max) for the settings preview card.</summary>
private string AbbrevPreview =>
string.IsNullOrWhiteSpace(model.CustomerAbbrev)
? "???"
: model.CustomerAbbrev.ToLowerInvariant()[..Math.Min(3, model.CustomerAbbrev.Length)];
private void OnCustomerNameInput(ChangeEventArgs e)
{
// Auto-suggest abbreviation from first 3 letters of first word
if (string.IsNullOrWhiteSpace(model.CustomerAbbrev))
{
var word = (e.Value?.ToString() ?? "").Split(' ', '-', '_').FirstOrDefault(w => w.Length > 0);
if (!string.IsNullOrEmpty(word))
model.CustomerAbbrev = word[..Math.Min(3, word.Length)].ToUpperInvariant();
}
}
private async Task HandleSubmit()
{
deploying = true;
resultMessage = null;
model.CustomerAbbrev = model.CustomerAbbrev?.ToUpperInvariant() ?? string.Empty;
try
{
if (!string.IsNullOrWhiteSpace(constraintsText))
{
model.Constraints = constraintsText
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(c => c.Trim())
.Where(c => !string.IsNullOrEmpty(c))
.ToList();
}
var result = await InstanceSvc.CreateInstanceAsync(model);
resultSuccess = result.Success;
resultMessage = result.Success
? $"Instance '{result.StackName}' deployed successfully in {result.DurationMs}ms ({result.ServiceCount} services)."
: $"Deployment failed: {result.ErrorMessage}";
if (result.Success)
{
var instance = (await InstanceSvc.ListInstancesAsync(1, 1, model.CustomerAbbrev.ToLowerInvariant())).Items.FirstOrDefault();
createdInstanceId = instance?.Id;
}
}
catch (Exception ex)
{
resultSuccess = false;
resultMessage = $"Error: {ex.Message}";
}
finally
{
deploying = false;
}
}
}

View File

@@ -1,116 +0,0 @@
namespace OTSSignsOrchestrator.Configuration;
public class FileLoggingOptions
{
public const string SectionName = "FileLogging";
public bool Enabled { get; set; } = true;
public string Path { get; set; } = "/var/log/xibo-admin";
public string RollingInterval { get; set; } = "Day";
public int RetentionDays { get; set; } = 30;
public long FileSizeLimitBytes { get; set; } = 100 * 1024 * 1024; // 100MB
}
public class AuthenticationOptions
{
public const string SectionName = "Authentication";
public string LocalAdminToken { get; set; } = string.Empty;
}
public class GitOptions
{
public const string SectionName = "Git";
public string CacheDir { get; set; } = "/var/cache/xibo-admin-templates";
public int CacheTtlMinutes { get; set; } = 60;
public int ShallowCloneDepth { get; set; } = 1;
}
public class DockerOptions
{
public const string SectionName = "Docker";
public string SocketPath { get; set; } = "unix:///var/run/docker.sock";
public List<string> DefaultConstraints { get; set; } = new() { "node.labels.xibo==true" };
public int DeployTimeoutSeconds { get; set; } = 30;
public bool ValidateBeforeDeploy { get; set; } = true;
}
public class XiboDefaultImages
{
public string Cms { get; set; } = "ghcr.io/xibosignage/xibo-cms:release-4.4.0";
public string Mysql { get; set; } = "mysql:8.4";
public string Memcached { get; set; } = "memcached:alpine";
public string QuickChart { get; set; } = "ianw/quickchart";
}
public class XiboOptions
{
public const string SectionName = "Xibo";
public XiboDefaultImages DefaultImages { get; set; } = new();
public int TestConnectionTimeoutSeconds { get; set; } = 10;
}
public class DatabaseOptions
{
public const string SectionName = "Database";
public string Provider { get; set; } = "Sqlite"; // Sqlite or PostgreSQL
}
/// <summary>
/// Admin-level MySQL connection used by the orchestrator to provision new customer databases.
/// Credentials are stored in app settings (encrypted at rest via Data Protection where available).
/// The generated per-customer password is NEVER stored here — it is placed directly into a Docker secret.
/// </summary>
public class MySqlAdminOptions
{
public const string SectionName = "MySqlAdmin";
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 3306;
public string AdminUser { get; set; } = "root";
public string AdminPassword { get; set; } = string.Empty;
/// <summary>If true, treat TLS/cert errors as non-fatal (useful for self-signed certs in dev).</summary>
public bool AllowInsecureTls { get; set; } = false;
}
/// <summary>
/// CIFS volume settings applied to every named Docker volume created for a new instance.
/// The credentials file on the remote host is written ephemerally via SSH and deleted immediately after
/// the docker volume create command completes.
/// </summary>
public class CifsOptions
{
public const string SectionName = "Cifs";
/// <summary>UNC-style device path, e.g. //fileserver.local/xibo-data</summary>
public string Device { get; set; } = string.Empty;
/// <summary>Hostname/IP of the CIFS server for the addr= mount option.</summary>
public string ServerAddr { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string MountOptions { get; set; } = "vers=3.0,file_mode=0660,dir_mode=0770";
}
/// <summary>
/// Defaults sourced from the Settings page, used to pre-populate or complete instance creation
/// without requiring the operator to retype them every time.
/// </summary>
public class InstanceDefaultsOptions
{
public const string SectionName = "InstanceDefaults";
/// <summary>Default Git template repo URL (operator can override per-instance).</summary>
public string TemplateRepoUrl { get; set; } = string.Empty;
public string? TemplateRepoPat { get; set; }
/// <summary>Template for CMS_SERVER_NAME. Use {abbrev} as placeholder, e.g. "{abbrev}x.ots-signs.com".</summary>
public string CmsServerNameTemplate { get; set; } = "{abbrev}.ots-signs.com";
public string SmtpServer { get; set; } = string.Empty;
public string SmtpUsername { get; set; } = string.Empty;
public string SmtpPassword { get; set; } = string.Empty;
/// <summary>Base host HTTP port; each new instance auto-increments from this value.</summary>
public int BaseHostHttpPort { get; set; } = 8080;
/// <summary>Template for the theme host path. Use {abbrev} as placeholder.</summary>
/// <summary>Static host path for the theme volume mount. Overridable per-instance.</summary>
public string ThemeHostPath { get; set; } = "/cms/ots-theme";
/// <summary>Template for the library CIFS volume sub-path. Use {abbrev} as placeholder.</summary>
public string LibraryShareSubPath { get; set; } = "{abbrev}-cms-library";
/// <summary>MySQL database name template. Use {abbrev}.</summary>
public string MySqlDatabaseTemplate { get; set; } = "{abbrev}_cms_db";
/// <summary>MySQL username template. Use {abbrev}.</summary>
public string MySqlUserTemplate { get; set; } = "{abbrev}_cms";
}

View File

@@ -1,173 +0,0 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Serilog;
using Serilog.Events;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Configuration;
public static class DependencyInjection
{
public static WebApplicationBuilder AddXiboSwarmServices(this WebApplicationBuilder builder)
{
var config = builder.Configuration;
// --- Options ---
builder.Services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
builder.Services.Configure<AuthenticationOptions>(config.GetSection(AuthenticationOptions.SectionName));
builder.Services.Configure<GitOptions>(config.GetSection(GitOptions.SectionName));
builder.Services.Configure<DockerOptions>(config.GetSection(DockerOptions.SectionName));
builder.Services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
builder.Services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
builder.Services.Configure<MySqlAdminOptions>(config.GetSection(MySqlAdminOptions.SectionName));
builder.Services.Configure<CifsOptions>(config.GetSection(CifsOptions.SectionName));
builder.Services.Configure<InstanceDefaultsOptions>(config.GetSection(InstanceDefaultsOptions.SectionName));
// --- Serilog ---
ConfigureSerilog(builder);
// --- Data Protection (encrypts secrets at rest) ---
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(
Path.Combine(builder.Environment.ContentRootPath, "keys")))
.SetApplicationName("OTSSignsOrchestrator");
// --- Database ---
var dbProvider = config.GetValue<string>("Database:Provider") ?? "Sqlite";
var connStr = config.GetConnectionString("Default") ?? "Data Source=xibo-admin.db";
builder.Services.AddDbContext<XiboContext>(options =>
{
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
options.UseNpgsql(connStr);
else
options.UseSqlite(connStr);
});
// --- Authentication ---
ConfigureAuthentication(builder);
// --- HTTP Clients ---
builder.Services.AddHttpClient("XiboApi")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
// Accept self-signed certs in dev
ServerCertificateCustomValidationCallback =
builder.Environment.IsDevelopment()
? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
: null!
});
builder.Services.AddHttpClient(); // Default factory
// --- Application Services ---
builder.Services.AddScoped<GitTemplateService>();
builder.Services.AddScoped<ComposeRenderService>();
builder.Services.AddScoped<ComposeValidationService>();
builder.Services.AddScoped<DockerCliService>();
builder.Services.AddScoped<DockerSecretsService>();
builder.Services.AddScoped<MySqlProvisionService>();
builder.Services.AddScoped<XiboApiService>();
builder.Services.AddScoped<InstanceService>();
builder.Services.AddScoped<OidcProviderService>();
// --- API Controllers ---
builder.Services.AddControllers();
return builder;
}
private static void ConfigureSerilog(WebApplicationBuilder builder)
{
var logConfig = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "OTSSignsOrchestrator")
.WriteTo.Console();
var fileLogging = builder.Configuration.GetSection(FileLoggingOptions.SectionName).Get<FileLoggingOptions>();
if (fileLogging?.Enabled == true)
{
var logPath = fileLogging.Path;
if (!Path.IsPathRooted(logPath))
logPath = Path.Combine(builder.Environment.ContentRootPath, logPath);
Directory.CreateDirectory(logPath);
// App log
logConfig.WriteTo.File(
Path.Combine(logPath, "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: fileLogging.RetentionDays,
fileSizeLimitBytes: fileLogging.FileSizeLimitBytes,
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}");
}
Log.Logger = logConfig.CreateLogger();
builder.Host.UseSerilog();
}
private static void ConfigureAuthentication(WebApplicationBuilder builder)
{
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
options.AccessDeniedPath = "/access-denied";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.SameSite = SameSiteMode.Strict;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
})
.AddScheme<AuthenticationSchemeOptions, AdminTokenAuthHandler>(
AppConstants.AdminTokenScheme, _ => { });
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole(AppConstants.AdminRole));
});
builder.Services.AddCascadingAuthenticationState();
}
public static WebApplication UseXiboSwarmMiddleware(this WebApplication app)
{
// Security headers
app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
await next();
});
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// Health check
app.MapGet("/healthz", () => Results.Ok(new
{
status = "healthy",
timestamp = DateTime.UtcNow
})).AllowAnonymous();
return app;
}
}

View File

@@ -1,36 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Models.DTOs;
public class CreateInstanceDto
{
/// <summary>Full display name of the customer (stored as YAML comment).</summary>
[Required, MaxLength(100)]
public string CustomerName { get; set; } = string.Empty;
/// <summary>3-letter uppercase abbreviation used as prefix in all stack resource names.</summary>
[Required, StringLength(3, MinimumLength = 3, ErrorMessage = "Abbreviation must be exactly 3 letters.")]
[RegularExpression("^[a-zA-Z]{3}$", ErrorMessage = "Abbreviation must be 3 letters (a-z, A-Z).")]
public string CustomerAbbrev { get; set; } = string.Empty;
// TemplateRepoUrl and TemplateRepoPat are sourced from app settings (Settings page) and
// optionally overridden here per-instance.
[MaxLength(500)]
public string? TemplateRepoUrl { get; set; }
[MaxLength(500)]
public string? TemplateRepoPat { get; set; }
/// <summary>Override the theme host path from settings (e.g. /cms/ots-theme).</summary>
[MaxLength(500)]
public string? ThemeHostPath { get; set; }
/// <summary>Comma-separated placement constraints.</summary>
public List<string>? Constraints { get; set; }
[MaxLength(200)]
public string? XiboUsername { get; set; }
[MaxLength(200)]
public string? XiboPassword { get; set; }
}

View File

@@ -1,99 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OTSSignsOrchestrator.Models.Entities;
public enum InstanceStatus
{
Deploying,
Active,
Error,
Deleted
}
public enum XiboApiTestStatus
{
Unknown,
Success,
Failed
}
public class CmsInstance
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required, MaxLength(100)]
public string CustomerName { get; set; } = string.Empty;
/// <summary>3-letter lowercase abbreviation used as stack resource prefix (e.g. "ots").</summary>
[Required, MaxLength(3)]
public string CustomerAbbrev { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string StackName { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string CmsServerName { get; set; } = string.Empty;
[Required, Range(1024, 65535)]
public int HostHttpPort { get; set; }
[Required, MaxLength(500)]
public string ThemeHostPath { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string LibraryHostPath { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string SmtpServer { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string SmtpUsername { get; set; } = string.Empty;
/// <summary>
/// JSON array of placement constraints, e.g. ["node.labels.xibo==true"]
/// </summary>
[MaxLength(2000)]
public string? Constraints { get; set; }
[Required, MaxLength(500)]
public string TemplateRepoUrl { get; set; } = string.Empty;
[MaxLength(500)]
public string? TemplateRepoPat { get; set; }
public DateTime? TemplateLastFetch { get; set; }
[MaxLength(100)]
public string? TemplateCacheKey { get; set; }
public InstanceStatus Status { get; set; } = InstanceStatus.Deploying;
/// <summary>
/// Encrypted Xibo admin username for API access.
/// </summary>
[MaxLength(500)]
public string? XiboUsername { get; set; }
/// <summary>
/// Encrypted Xibo admin password for API access.
/// Never logged; encrypted at rest via Data Protection.
/// </summary>
[MaxLength(1000)]
public string? XiboPassword { get; set; }
public XiboApiTestStatus XiboApiTestStatus { get; set; } = XiboApiTestStatus.Unknown;
public DateTime? XiboApiTestedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Soft delete marker.</summary>
public DateTime? DeletedAt { get; set; }
// Navigation properties
public ICollection<OperationLog> OperationLogs { get; set; } = new List<OperationLog>();
}

View File

@@ -1,394 +0,0 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Models.DTOs;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Renders a single self-contained Compose v3.9 YAML from the template and user/settings inputs.
/// The output file has no separate env_file — all environment variables are inlined.
/// Service and volume names are prefixed with the 3-letter customer abbreviation.
/// CIFS-backed named volumes are rendered with driver_opts so they can be created on the swarm.
/// </summary>
public class ComposeRenderService
{
private readonly XiboOptions _xiboOptions;
private readonly DockerOptions _dockerOptions;
private readonly CifsOptions _cifsOptions;
private readonly ILogger<ComposeRenderService> _logger;
public ComposeRenderService(
IOptions<XiboOptions> xiboOptions,
IOptions<DockerOptions> dockerOptions,
IOptions<CifsOptions> cifsOptions,
ILogger<ComposeRenderService> logger)
{
_xiboOptions = xiboOptions.Value;
_dockerOptions = dockerOptions.Value;
_cifsOptions = cifsOptions.Value;
_logger = logger;
}
/// <summary>
/// Render a final Compose YAML from the template + user inputs + secrets.
/// </summary>
public string Render(RenderContext ctx)
{
_logger.LogInformation("Rendering Compose for stack: {StackName} (abbrev={Abbrev})", ctx.StackName, ctx.CustomerAbbrev);
// Parse template YAML
var yaml = new YamlStream();
using (var reader = new StringReader(ctx.TemplateYaml))
{
yaml.Load(reader);
}
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
// Ensure version
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
// Process services
EnsureServices(root, ctx);
// Process volumes
EnsureVolumes(root, ctx);
// Process secrets
EnsureSecrets(root, ctx);
// Serialize back to YAML
using var writer = new StringWriter();
yaml.Save(writer, assignAnchors: false);
var output = writer.ToString();
// Clean up YAML stream terminators
output = output.Replace("...\n", "").Replace("...", "");
// Prepend customer comment
output = $"# Customer: {ctx.CustomerName}\n" + output;
_logger.LogDebug("Compose rendered: {ServiceCount} services, {SecretCount} secrets",
GetServiceCount(root), ctx.SecretNames.Count);
return output;
}
private void EnsureServices(YamlMappingNode root, RenderContext ctx)
{
if (!root.Children.ContainsKey(new YamlScalarNode("services")))
root.Children[new YamlScalarNode("services")] = new YamlMappingNode();
var services = (YamlMappingNode)root.Children[new YamlScalarNode("services")];
// Clear any services from template — we always build them deterministically
services.Children.Clear();
EnsureCmsWeb(services, ctx);
EnsureMemcached(services, ctx);
EnsureQuickChart(services, ctx);
EnsureNewt(services, ctx);
}
private void EnsureCmsWeb(YamlMappingNode services, RenderContext ctx)
{
var a = ctx.CustomerAbbrev;
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{a}-web")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Cms);
// Build environment — merge template.env first, then apply our required overrides
var env = BuildEnvFromTemplate(ctx.TemplateEnvValues, ctx);
svc.Children[new YamlScalarNode("environment")] = env;
// Ports
svc.Children[new YamlScalarNode("ports")] = new YamlSequenceNode(
new YamlScalarNode($"{ctx.HostHttpPort}:80")
);
// Named volumes (CIFS-backed) + theme bind mount
svc.Children[new YamlScalarNode("volumes")] = new YamlSequenceNode(
new YamlScalarNode($"{a}-cms-custom:/var/www/cms/custom"),
new YamlScalarNode($"{a}-cms-backup:/var/www/backup"),
new YamlScalarNode($"{ctx.ThemeHostPath}:/var/www/cms/web/theme/custom"),
new YamlScalarNode($"{a}-cms-library:/var/www/cms/library"),
new YamlScalarNode($"{a}-cms-userscripts:/var/www/cms/web/userscripts"),
new YamlScalarNode($"{a}-cms-ca-certs:/var/www/cms/ca-certs")
);
// Secrets
svc.Children[new YamlScalarNode("secrets")] = new YamlSequenceNode(
ctx.SecretNames.Select(n => (YamlNode)new YamlScalarNode(n)).ToList()
);
// Network
var netNode = new YamlMappingNode();
var aliasNode = new YamlMappingNode();
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("web"));
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
svc.Children[new YamlScalarNode("networks")] = netNode;
// Deploy
var deploy = BuildDeploy(ctx, memoryLimit: "1G");
svc.Children[new YamlScalarNode("deploy")] = deploy;
}
private void EnsureMemcached(YamlMappingNode services, RenderContext ctx)
{
var a = ctx.CustomerAbbrev;
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{a}-memcached")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Memcached);
svc.Children[new YamlScalarNode("command")] = new YamlSequenceNode(
new YamlScalarNode("memcached"), new YamlScalarNode("-m"), new YamlScalarNode("15")
);
var netNode = new YamlMappingNode();
var aliasNode = new YamlMappingNode();
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("memcached"));
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
svc.Children[new YamlScalarNode("networks")] = netNode;
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx, memoryLimit: "100M");
}
private void EnsureQuickChart(YamlMappingNode services, RenderContext ctx)
{
var a = ctx.CustomerAbbrev;
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{a}-quickchart")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.QuickChart);
var netNode = new YamlMappingNode();
var aliasNode = new YamlMappingNode();
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("quickchart"));
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
svc.Children[new YamlScalarNode("networks")] = netNode;
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx);
}
private void EnsureNewt(YamlMappingNode services, RenderContext ctx)
{
var a = ctx.CustomerAbbrev;
// Only add newt if the template env provides the newt config
if (!ctx.TemplateEnvValues.ContainsKey("NEWT_ID") && !ctx.TemplateEnvValues.ContainsKey("PANGOLIN_ENDPOINT"))
return;
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{a}-newt")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode("fosrl/newt");
var env = new YamlMappingNode();
if (ctx.TemplateEnvValues.TryGetValue("PANGOLIN_ENDPOINT", out var endpoint))
env.Children[new YamlScalarNode("PANGOLIN_ENDPOINT")] = new YamlScalarNode(endpoint);
if (ctx.TemplateEnvValues.TryGetValue("NEWT_ID", out var newtId))
env.Children[new YamlScalarNode("NEWT_ID")] = new YamlScalarNode(newtId);
if (ctx.TemplateEnvValues.TryGetValue("NEWT_SECRET", out var newtSecret))
env.Children[new YamlScalarNode("NEWT_SECRET")] = new YamlScalarNode(newtSecret);
svc.Children[new YamlScalarNode("environment")] = env;
var netNode = new YamlMappingNode();
netNode.Children[new YamlScalarNode($"{a}-net")] = new YamlMappingNode();
svc.Children[new YamlScalarNode("networks")] = netNode;
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx);
}
/// <summary>
/// Build the environment mapping for cms-web: start with template.env values,
/// then apply all required orchestrator overrides.
/// </summary>
private YamlMappingNode BuildEnvFromTemplate(Dictionary<string, string> templateEnv, RenderContext ctx)
{
var env = new YamlMappingNode();
// Apply template values first
foreach (var (k, v) in templateEnv)
env.Children[new YamlScalarNode(k)] = new YamlScalarNode(v);
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(ctx.CustomerAbbrev);
// Required overrides — these always win over template values
env.Children[new YamlScalarNode("CMS_USE_MEMCACHED")] = new YamlScalarNode("true");
env.Children[new YamlScalarNode("MEMCACHED_HOST")] = new YamlScalarNode("memcached");
env.Children[new YamlScalarNode("CMS_SERVER_NAME")] = new YamlScalarNode(ctx.CmsServerName);
env.Children[new YamlScalarNode("MYSQL_HOST")] = new YamlScalarNode(ctx.MySqlHost);
env.Children[new YamlScalarNode("MYSQL_PORT")] = new YamlScalarNode(ctx.MySqlPort.ToString());
env.Children[new YamlScalarNode("MYSQL_DATABASE")] = new YamlScalarNode(ctx.MySqlDatabase);
env.Children[new YamlScalarNode("MYSQL_USER")] = new YamlScalarNode(ctx.MySqlUser);
env.Children[new YamlScalarNode("MYSQL_PASSWORD")] = new YamlScalarNode($"/run/secrets/{mysqlSecretName}");
env.Children[new YamlScalarNode("CMS_SMTP_SERVER")] = new YamlScalarNode(ctx.SmtpServer);
env.Children[new YamlScalarNode("CMS_SMTP_USERNAME")] = new YamlScalarNode(ctx.SmtpUsername);
env.Children[new YamlScalarNode("CMS_SMTP_PASSWORD")] = new YamlScalarNode(ctx.SmtpPassword);
env.Children[new YamlScalarNode("CMS_SMTP_USE_TLS")] = new YamlScalarNode("YES");
env.Children[new YamlScalarNode("CMS_SMTP_USE_STARTTLS")] = new YamlScalarNode("YES");
env.Children[new YamlScalarNode("CMS_SMTP_REWRITE_DOMAIN")] = new YamlScalarNode(ctx.SmtpRewriteDomain);
env.Children[new YamlScalarNode("CMS_SMTP_FROM_LINE_OVERRIDE")] = new YamlScalarNode("NO");
env.Children[new YamlScalarNode("CMS_PHP_POST_MAX_SIZE")] = new YamlScalarNode("10G");
env.Children[new YamlScalarNode("CMS_PHP_UPLOAD_MAX_FILESIZE")] = new YamlScalarNode("10G");
env.Children[new YamlScalarNode("CMS_PHP_MAX_EXECUTION_TIME")] = new YamlScalarNode("600");
return env;
}
private static YamlMappingNode BuildDeploy(RenderContext ctx, string? memoryLimit = null)
{
var deploy = new YamlMappingNode();
var restartPolicy = new YamlMappingNode();
restartPolicy.Children[new YamlScalarNode("condition")] = new YamlScalarNode("any");
deploy.Children[new YamlScalarNode("restart_policy")] = restartPolicy;
if (memoryLimit != null)
{
var resources = new YamlMappingNode();
var limits = new YamlMappingNode();
limits.Children[new YamlScalarNode("memory")] = new YamlScalarNode(memoryLimit);
resources.Children[new YamlScalarNode("limits")] = limits;
deploy.Children[new YamlScalarNode("resources")] = resources;
}
if (ctx.Constraints != null && ctx.Constraints.Count > 0)
{
var placement = new YamlMappingNode();
placement.Children[new YamlScalarNode("constraints")] = new YamlSequenceNode(
ctx.Constraints.Select(c => (YamlNode)new YamlScalarNode(c)).ToList()
);
deploy.Children[new YamlScalarNode("placement")] = placement;
}
return deploy;
}
private void EnsureVolumes(YamlMappingNode root, RenderContext ctx)
{
var a = ctx.CustomerAbbrev;
var volumesKey = new YamlScalarNode("volumes");
var volumes = new YamlMappingNode();
root.Children[volumesKey] = volumes;
// CIFS-backed named volumes
var cifsVolumes = new[] { "cms-custom", "cms-backup", "cms-library", "cms-userscripts", "cms-ca-certs" };
foreach (var vol in cifsVolumes)
{
var volName = $"{a}-{vol}";
volumes.Children[new YamlScalarNode(volName)] = BuildCifsVolumeNode(vol, ctx);
}
// Plain local volume for DB (not CIFS — stays on the node)
volumes.Children[new YamlScalarNode($"{a}-db-data")] = new YamlMappingNode();
}
/// <summary>
/// Build a CIFS-driver volume node matching the pattern:
/// <code>
/// driver: local
/// driver_opts:
/// type: cifs
/// device: "//fileserver.local/share/subpath"
/// o: "addr=fileserver.local,credentials=/etc/docker-cifs-credentials,file_mode=0660,dir_mode=0770,vers=3.0"
/// </code>
/// The credentials= path points to a credentials file pre-deployed on the target host.
/// </summary>
private YamlMappingNode BuildCifsVolumeNode(string subPath, RenderContext ctx)
{
var node = new YamlMappingNode();
node.Children[new YamlScalarNode("driver")] = new YamlScalarNode("local");
var opts = new YamlMappingNode();
opts.Children[new YamlScalarNode("type")] = new YamlScalarNode("cifs");
// Append the sub-path derived from the abbreviation to the base CIFS device
var device = _cifsOptions.Device.TrimEnd('/') + "/" + ctx.CustomerAbbrev + "-" + subPath;
opts.Children[new YamlScalarNode("device")] = new YamlScalarNode(device);
// The credentials file path on the remote host — written ephemerally during provisioning
var oValue = $"addr={_cifsOptions.ServerAddr},credentials={ctx.CifsCredentialsFilePath},{_cifsOptions.MountOptions}";
opts.Children[new YamlScalarNode("o")] = new YamlScalarNode(oValue);
node.Children[new YamlScalarNode("driver_opts")] = opts;
return node;
}
private void EnsureSecrets(YamlMappingNode root, RenderContext ctx)
{
var secretsKey = new YamlScalarNode("secrets");
var secrets = new YamlMappingNode();
root.Children[secretsKey] = secrets;
foreach (var secretName in ctx.SecretNames)
{
secrets.Children[new YamlScalarNode(secretName)] = new YamlMappingNode
{
{ "external", "true" }
};
}
}
private void EnsureNetworks(YamlMappingNode root, RenderContext ctx)
{
var networksKey = new YamlScalarNode("networks");
var networks = new YamlMappingNode();
root.Children[networksKey] = networks;
var netDef = new YamlMappingNode();
netDef.Children[new YamlScalarNode("driver")] = new YamlScalarNode("overlay");
netDef.Children[new YamlScalarNode("attachable")] = new YamlScalarNode("false");
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = netDef;
}
private static int GetServiceCount(YamlMappingNode root)
{
var servicesKey = new YamlScalarNode("services");
if (root.Children.ContainsKey(servicesKey) && root.Children[servicesKey] is YamlMappingNode svc)
return svc.Children.Count;
return 0;
}
}
/// <summary>
/// All inputs needed to render a single Compose file.
/// </summary>
public class RenderContext
{
public string CustomerName { get; set; } = string.Empty;
/// <summary>3-letter abbreviation used as naming prefix (e.g. "ots").</summary>
public string CustomerAbbrev { get; set; } = string.Empty;
public string StackName { get; set; } = string.Empty;
public string CmsServerName { get; set; } = string.Empty;
public int HostHttpPort { get; set; }
public string ThemeHostPath { get; set; } = string.Empty;
// MySQL coordinates (external server — NOT a sidecar container)
public string MySqlHost { get; set; } = string.Empty;
public int MySqlPort { get; set; } = 3306;
public string MySqlDatabase { get; set; } = string.Empty;
public string MySqlUser { get; set; } = string.Empty;
// SMTP
public string SmtpServer { get; set; } = string.Empty;
public string SmtpUsername { get; set; } = string.Empty;
public string SmtpPassword { get; set; } = string.Empty;
public string SmtpRewriteDomain { get; set; } = string.Empty;
/// <summary>
/// Path on the remote target host where the CIFS credentials file will be placed ephemerally during provisioning.
/// Written as the credentials= value in volume driver_opts.
/// </summary>
public string CifsCredentialsFilePath { get; set; } = "/etc/docker-cifs-credentials";
public string TemplateYaml { get; set; } = string.Empty;
/// <summary>Parsed key/value pairs from template.env (placeholder-substituted).</summary>
public Dictionary<string, string> TemplateEnvValues { get; set; } = new();
public List<string> Constraints { get; set; } = new();
/// <summary>Secret names to declare as external in the compose file.</summary>
public List<string> SecretNames { get; set; } = new();
}

View File

@@ -1,551 +0,0 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Models.DTOs;
using OTSSignsOrchestrator.Models.Entities;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
/// Coordinates GitTemplateService, ComposeRenderService, ComposeValidationService,
/// DockerCliService, DockerSecretsService, MySqlProvisionService, and XiboApiService.
/// </summary>
public class InstanceService
{
private readonly XiboContext _db;
private readonly GitTemplateService _git;
private readonly ComposeRenderService _compose;
private readonly ComposeValidationService _validation;
private readonly DockerCliService _docker;
private readonly DockerSecretsService _secrets;
private readonly MySqlProvisionService _mysql;
private readonly XiboApiService _xibo;
private readonly DockerOptions _dockerOptions;
private readonly MySqlAdminOptions _mysqlAdminOptions;
private readonly CifsOptions _cifsOptions;
private readonly InstanceDefaultsOptions _instanceDefaults;
private readonly ILogger<InstanceService> _logger;
public InstanceService(
XiboContext db,
GitTemplateService git,
ComposeRenderService compose,
ComposeValidationService validation,
DockerCliService docker,
DockerSecretsService secrets,
MySqlProvisionService mysql,
XiboApiService xibo,
IOptions<DockerOptions> dockerOptions,
IOptions<MySqlAdminOptions> mysqlAdminOptions,
IOptions<CifsOptions> cifsOptions,
IOptions<InstanceDefaultsOptions> instanceDefaults,
ILogger<InstanceService> logger)
{
_db = db;
_git = git;
_compose = compose;
_validation = validation;
_docker = docker;
_secrets = secrets;
_mysql = mysql;
_xibo = xibo;
_dockerOptions = dockerOptions.Value;
_mysqlAdminOptions = mysqlAdminOptions.Value;
_cifsOptions = cifsOptions.Value;
_instanceDefaults = instanceDefaults.Value;
_logger = logger;
}
/// <summary>
/// Create and deploy a new CMS instance.
/// Steps:
/// 1. Validate abbreviation and uniqueness
/// 2. Fetch templates from Git (configured repo)
/// 3. Generate secrets in memory, create on Swarm — never persisted
/// 4. Provision MySQL DB + user via direct connection
/// 5. Render merged single-file Compose YAML
/// 6. Validate Compose
/// 7. Deploy stack
/// 8. Persist instance metadata (no secret values stored)
/// </summary>
public async Task<DeploymentResultDto> CreateInstanceAsync(CreateInstanceDto dto, string? userId = null, string? ipAddress = null)
{
var sw = Stopwatch.StartNew();
var opLog = StartOperation(OperationType.Create, userId, ipAddress);
// Resolve abbreviation to lowercase for naming, uppercase for display
var abbrev = dto.CustomerAbbrev.ToLowerInvariant();
// Derive stack name and resource names from abbreviation
var stackName = abbrev;
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(abbrev);
// Resolve per-instance settings (use DTO override or fall back to global defaults)
var templateRepoUrl = !string.IsNullOrWhiteSpace(dto.TemplateRepoUrl)
? dto.TemplateRepoUrl
: _instanceDefaults.TemplateRepoUrl;
var templateRepoPat = dto.TemplateRepoPat ?? _instanceDefaults.TemplateRepoPat;
var cmsServerName = _instanceDefaults.CmsServerNameTemplate.Replace("{abbrev}", abbrev);
var themeHostPath = !string.IsNullOrWhiteSpace(dto.ThemeHostPath)
? dto.ThemeHostPath
: _instanceDefaults.ThemeHostPath;
var mysqlDatabase = _instanceDefaults.MySqlDatabaseTemplate.Replace("{abbrev}", abbrev);
var mysqlUser = _instanceDefaults.MySqlUserTemplate.Replace("{abbrev}", abbrev);
var smtpRewriteDomain = ExtractDomain(_instanceDefaults.SmtpUsername);
try
{
// ----------------------------------------------------------------
// Step 1 — Validate no duplicate stack/abbreviation
// ----------------------------------------------------------------
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
var existing = await _db.CmsInstances.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
if (existing != null)
throw new InvalidOperationException($"An instance with abbreviation '{dto.CustomerAbbrev}' (stack '{stackName}') already exists.");
// ----------------------------------------------------------------
// Step 2 — Clone / fetch templates from configured Git repo
// ----------------------------------------------------------------
if (string.IsNullOrWhiteSpace(templateRepoUrl))
throw new InvalidOperationException("No template repo URL configured. Set InstanceDefaults:TemplateRepoUrl in settings.");
var template = await _git.FetchAsync(templateRepoUrl, templateRepoPat);
// Parse template.env into key/value dictionary; apply abbreviation placeholder substitution
var templateEnvValues = ParseEnvLines(template.EnvLines, abbrev);
// ----------------------------------------------------------------
// Step 3 — Generate secrets in memory; create on Swarm via Docker
// NEVER store generated password values in DB or disk
// ----------------------------------------------------------------
var mysqlPassword = GenerateRandomPassword(32);
var mysqlSecretResult = await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
_logger.LogInformation("MySQL secret '{SecretName}' — created={Created}", mysqlSecretName, mysqlSecretResult.Created);
// Track secret metadata in DB (name + customer, NOT the value)
await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName);
// ----------------------------------------------------------------
// Step 4 — Provision MySQL database and user
// ----------------------------------------------------------------
await _mysql.ProvisionAsync(mysqlDatabase, mysqlUser, mysqlPassword);
// mysqlPassword goes out of scope after this method — it is not stored anywhere
// ----------------------------------------------------------------
// Step 5 — Build render context and render single Compose YAML
// ----------------------------------------------------------------
var constraints = (dto.Constraints is { Count: > 0 }) ? dto.Constraints : _dockerOptions.DefaultConstraints;
var hostHttpPort = _instanceDefaults.BaseHostHttpPort; // TODO: auto-increment per instance
var renderCtx = new RenderContext
{
CustomerName = dto.CustomerName,
CustomerAbbrev = abbrev,
StackName = stackName,
CmsServerName = cmsServerName,
HostHttpPort = hostHttpPort,
ThemeHostPath = themeHostPath,
MySqlHost = _mysqlAdminOptions.Host,
MySqlPort = _mysqlAdminOptions.Port,
MySqlDatabase = mysqlDatabase,
MySqlUser = mysqlUser,
SmtpServer = _instanceDefaults.SmtpServer,
SmtpUsername = _instanceDefaults.SmtpUsername,
SmtpPassword = _instanceDefaults.SmtpPassword,
SmtpRewriteDomain = smtpRewriteDomain,
TemplateYaml = template.Yaml,
TemplateEnvValues = templateEnvValues,
Constraints = constraints,
SecretNames = new List<string> { mysqlSecretName },
CifsCredentialsFilePath = "/etc/docker-cifs-credentials"
};
var composeYaml = _compose.Render(renderCtx);
// ----------------------------------------------------------------
// Step 6 — Validate Compose
// ----------------------------------------------------------------
if (_dockerOptions.ValidateBeforeDeploy)
{
var validationResult = _validation.Validate(composeYaml);
if (!validationResult.IsValid)
{
var errorMsg = string.Join("; ", validationResult.Errors);
throw new InvalidOperationException($"Compose validation failed: {errorMsg}");
}
}
// ----------------------------------------------------------------
// Step 7 — Deploy stack via Docker
// ----------------------------------------------------------------
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
if (!deployResult.Success)
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
// ----------------------------------------------------------------
// Step 8 — Persist instance metadata (no secret values stored)
// ----------------------------------------------------------------
var instance = new CmsInstance
{
CustomerName = dto.CustomerName,
CustomerAbbrev = abbrev,
StackName = stackName,
CmsServerName = cmsServerName,
HostHttpPort = hostHttpPort,
ThemeHostPath = themeHostPath,
LibraryHostPath = _instanceDefaults.LibraryShareSubPath.Replace("{abbrev}", abbrev),
SmtpServer = _instanceDefaults.SmtpServer,
SmtpUsername = _instanceDefaults.SmtpUsername,
Constraints = JsonSerializer.Serialize(constraints),
TemplateRepoUrl = templateRepoUrl,
TemplateRepoPat = templateRepoPat,
TemplateLastFetch = template.FetchedAt,
Status = InstanceStatus.Active,
XiboUsername = dto.XiboUsername,
XiboPassword = dto.XiboPassword,
XiboApiTestStatus = XiboApiTestStatus.Unknown
};
_db.CmsInstances.Add(instance);
sw.Stop();
opLog.InstanceId = instance.Id;
opLog.Status = OperationStatus.Success;
opLog.Message = $"Instance deployed: {stackName}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
stackName, instance.Id, sw.ElapsedMilliseconds);
deployResult.ServiceCount = 4;
deployResult.Message = "Instance deployed successfully.";
return deployResult;
}
catch (Exception ex)
{
sw.Stop();
opLog.Status = OperationStatus.Failure;
opLog.Message = $"Create failed: {ex.Message}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogError(ex, "Instance create failed: abbrev={Abbrev}", abbrev);
throw;
}
}
/// <summary>
/// Update and redeploy an existing CMS instance.
/// </summary>
public async Task<DeploymentResultDto> UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null, string? ipAddress = null)
{
var sw = Stopwatch.StartNew();
var opLog = StartOperation(OperationType.Update, userId, ipAddress);
try
{
var instance = await _db.CmsInstances.FindAsync(id)
?? throw new KeyNotFoundException($"Instance {id} not found.");
_logger.LogInformation("Updating instance: {StackName} (id={Id})", instance.StackName, id);
// Apply updates
if (dto.TemplateRepoUrl != null) instance.TemplateRepoUrl = dto.TemplateRepoUrl;
if (dto.TemplateRepoPat != null) instance.TemplateRepoPat = dto.TemplateRepoPat;
if (dto.SmtpServer != null) instance.SmtpServer = dto.SmtpServer;
if (dto.SmtpUsername != null) instance.SmtpUsername = dto.SmtpUsername;
if (dto.Constraints != null) instance.Constraints = JsonSerializer.Serialize(dto.Constraints);
if (dto.XiboUsername != null) instance.XiboUsername = dto.XiboUsername;
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
// Re-fetch templates
var template = await _git.FetchAsync(instance.TemplateRepoUrl, instance.TemplateRepoPat, forceRefresh: true);
instance.TemplateLastFetch = template.FetchedAt;
// Re-render Compose
var constraints = string.IsNullOrEmpty(instance.Constraints)
? _dockerOptions.DefaultConstraints
: JsonSerializer.Deserialize<List<string>>(instance.Constraints) ?? _dockerOptions.DefaultConstraints;
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(instance.CustomerName);
var abbrev = instance.CustomerAbbrev;
var templateEnvValues = ParseEnvLines(template.EnvLines, abbrev);
var smtpRewriteDomain = ExtractDomain(instance.SmtpUsername);
var renderCtx = new RenderContext
{
CustomerName = instance.CustomerName,
CustomerAbbrev = abbrev,
StackName = instance.StackName,
CmsServerName = instance.CmsServerName,
HostHttpPort = instance.HostHttpPort,
ThemeHostPath = instance.ThemeHostPath,
MySqlHost = _mysqlAdminOptions.Host,
MySqlPort = _mysqlAdminOptions.Port,
MySqlDatabase = _instanceDefaults.MySqlDatabaseTemplate.Replace("{abbrev}", abbrev),
MySqlUser = _instanceDefaults.MySqlUserTemplate.Replace("{abbrev}", abbrev),
SmtpServer = instance.SmtpServer,
SmtpUsername = instance.SmtpUsername,
SmtpPassword = _instanceDefaults.SmtpPassword,
SmtpRewriteDomain = smtpRewriteDomain,
TemplateYaml = template.Yaml,
TemplateEnvValues = templateEnvValues,
Constraints = constraints,
SecretNames = new List<string> { mysqlSecretName },
CifsCredentialsFilePath = "/etc/docker-cifs-credentials"
};
var composeYaml = _compose.Render(renderCtx);
// Validate
if (_dockerOptions.ValidateBeforeDeploy)
{
var validationResult = _validation.Validate(composeYaml);
if (!validationResult.IsValid)
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
}
// Redeploy
var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true);
if (!deployResult.Success)
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");
instance.UpdatedAt = DateTime.UtcNow;
instance.Status = InstanceStatus.Active;
sw.Stop();
opLog.InstanceId = instance.Id;
opLog.Status = OperationStatus.Success;
opLog.Message = $"Instance updated: {instance.StackName}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogInformation("Instance updated: {StackName} (id={Id}) | duration={DurationMs}ms",
instance.StackName, id, sw.ElapsedMilliseconds);
deployResult.ServiceCount = 4;
deployResult.Message = "Instance updated and redeployed.";
return deployResult;
}
catch (Exception ex)
{
sw.Stop();
opLog.Status = OperationStatus.Failure;
opLog.Message = $"Update failed: {ex.Message}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogError(ex, "Instance update failed (id={Id})", id);
throw;
}
}
/// <summary>
/// Delete a CMS instance (soft delete in DB; removes stack from Swarm).
/// </summary>
public async Task<DeploymentResultDto> DeleteInstanceAsync(
Guid id, bool retainSecrets = false, bool clearXiboCreds = true,
string? userId = null, string? ipAddress = null)
{
var sw = Stopwatch.StartNew();
var opLog = StartOperation(OperationType.Delete, userId, ipAddress);
try
{
var instance = await _db.CmsInstances.FindAsync(id)
?? throw new KeyNotFoundException($"Instance {id} not found.");
_logger.LogInformation("Deleting instance: {StackName} (id={Id}) retainSecrets={RetainSecrets}",
instance.StackName, id, retainSecrets);
// Remove stack
var result = await _docker.RemoveStackAsync(instance.StackName);
// Optionally remove secrets
if (!retainSecrets)
{
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(instance.CustomerName);
await _secrets.DeleteSecretAsync(mysqlSecretName);
var secretMeta = await _db.SecretMetadata
.FirstOrDefaultAsync(s => s.Name == mysqlSecretName);
if (secretMeta != null)
_db.SecretMetadata.Remove(secretMeta);
}
// Soft delete instance
instance.Status = InstanceStatus.Deleted;
instance.DeletedAt = DateTime.UtcNow;
instance.UpdatedAt = DateTime.UtcNow;
if (clearXiboCreds)
{
instance.XiboUsername = null;
instance.XiboPassword = null;
instance.XiboApiTestStatus = XiboApiTestStatus.Unknown;
}
sw.Stop();
opLog.InstanceId = instance.Id;
opLog.Status = OperationStatus.Success;
opLog.Message = $"Instance deleted: {instance.StackName}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogInformation("Instance deleted: {StackName} (id={Id}) | duration={DurationMs}ms",
instance.StackName, id, sw.ElapsedMilliseconds);
result.Message = "Instance deleted.";
return result;
}
catch (Exception ex)
{
sw.Stop();
opLog.Status = OperationStatus.Failure;
opLog.Message = $"Delete failed: {ex.Message}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogError(ex, "Instance delete failed (id={Id})", id);
throw;
}
}
/// <summary>
/// Get an instance by ID.
/// </summary>
public async Task<CmsInstance?> GetInstanceAsync(Guid id)
{
return await _db.CmsInstances.FindAsync(id);
}
/// <summary>
/// List all active instances with optional filter.
/// </summary>
public async Task<(List<CmsInstance> Items, int TotalCount)> ListInstancesAsync(
int page = 1, int pageSize = 50, string? filter = null)
{
var query = _db.CmsInstances.AsQueryable();
if (!string.IsNullOrWhiteSpace(filter))
{
query = query.Where(i =>
i.CustomerName.Contains(filter) ||
i.StackName.Contains(filter));
}
var total = await query.CountAsync();
var items = await query
.OrderByDescending(i => i.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, total);
}
/// <summary>
/// Test the Xibo API connection for an instance.
/// </summary>
public async Task<XiboTestResult> TestXiboConnectionAsync(Guid id)
{
var instance = await _db.CmsInstances.FindAsync(id)
?? throw new KeyNotFoundException($"Instance {id} not found.");
if (string.IsNullOrEmpty(instance.XiboUsername) || string.IsNullOrEmpty(instance.XiboPassword))
return new XiboTestResult { IsValid = false, Message = "No Xibo credentials stored." };
var url = $"http://localhost:{instance.HostHttpPort}";
var result = await _xibo.TestConnectionAsync(url, instance.XiboUsername, instance.XiboPassword);
instance.XiboApiTestStatus = result.IsValid ? XiboApiTestStatus.Success : XiboApiTestStatus.Failed;
instance.XiboApiTestedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return result;
}
private async Task EnsureSecretMetadata(string name, bool isGlobal, string? customerName)
{
var existing = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == name);
if (existing == null)
{
_db.SecretMetadata.Add(new SecretMetadata
{
Name = name,
IsGlobal = isGlobal,
CustomerName = customerName
});
}
}
private static OperationLog StartOperation(OperationType type, string? userId, string? ipAddress)
{
return new OperationLog
{
Operation = type,
UserId = userId,
IpAddress = ipAddress,
Status = OperationStatus.Pending
};
}
private static string GenerateRandomPassword(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
return RandomNumberGenerator.GetString(chars, length);
}
/// <summary>
/// Parse template.env lines into a key/value dictionary.
/// Replaces {abbrev} placeholders with the customer abbreviation.
/// Skips empty lines and comments.
/// </summary>
private static Dictionary<string, string> ParseEnvLines(IEnumerable<string> lines, string abbrev)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines)
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
continue;
var eqIdx = trimmed.IndexOf('=');
if (eqIdx <= 0) continue;
var key = trimmed[..eqIdx].Trim();
var value = trimmed[(eqIdx + 1)..].Trim()
.Replace("{CUSTOMERABBREVIATION}", abbrev)
.Replace("{CUSTOMERABBREV}", abbrev)
.Replace("{abbrev}", abbrev);
result[key] = value;
}
return result;
}
/// <summary>Extracts the domain part from an email address, e.g. "user@ots-signs.com" → "ots-signs.com".</summary>
private static string ExtractDomain(string email)
{
var atIdx = email?.IndexOf('@') ?? -1;
return atIdx >= 0 ? email![(atIdx + 1)..] : string.Empty;
}
}

View File

@@ -1,86 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" }
]
},
"FileLogging": {
"Enabled": true,
"Path": "/var/log/xibo-admin",
"RollingInterval": "Day",
"RetentionDays": 30,
"FileSizeLimitBytes": 104857600
},
"Authentication": {
"LocalAdminToken": ""
},
"Git": {
"CacheDir": "/var/cache/xibo-admin-templates",
"CacheTtlMinutes": 60,
"ShallowCloneDepth": 1
},
"Docker": {
"SocketPath": "unix:///var/run/docker.sock",
"DefaultConstraints": [ "node.labels.xibo==true" ],
"DeployTimeoutSeconds": 30,
"ValidateBeforeDeploy": true
},
"Xibo": {
"DefaultImages": {
"Cms": "ghcr.io/xibosignage/xibo-cms:release-4.4.0",
"Mysql": "mysql:8.4",
"Memcached": "memcached:alpine",
"QuickChart": "ianw/quickchart"
},
"TestConnectionTimeoutSeconds": 10
},
"Database": {
"Provider": "Sqlite"
},
"MySqlAdmin": {
"Host": "cms-sql.otshosting.app",
"Port": 3306,
"AdminUser": "root",
"AdminPassword": "",
"AllowInsecureTls": false
},
"Cifs": {
"Device": "//fileserver.local/xibo-data",
"ServerAddr": "fileserver.local",
"Username": "",
"Password": "",
"MountOptions": "vers=3.0,file_mode=0660,dir_mode=0770"
},
"InstanceDefaults": {
"TemplateRepoUrl": "",
"TemplateRepoPat": null,
"CmsServerNameTemplate": "{abbrev}.ots-signs.com",
"SmtpServer": "smtp.azurecomm.net:587",
"SmtpUsername": "",
"SmtpPassword": "",
"BaseHostHttpPort": 8080,
"ThemeHostPath": "/cms/ots-theme",
"LibraryShareSubPath": "{abbrev}-cms-library",
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
"MySqlUserTemplate": "{abbrev}_cms"
},
"ConnectionStrings": {
"Default": "Data Source=xibo-admin.db"
},
"AllowedHosts": "*"
}

125
template.yml Normal file
View File

@@ -0,0 +1,125 @@
# Customer: {{CUSTOMER_NAME}}
version: "3.9"
services:
{{ABBREV}}-web:
image: {{CMS_IMAGE}}
environment:
CMS_USE_MEMCACHED: "true"
MEMCACHED_HOST: memcached
MYSQL_HOST: {{MYSQL_HOST}}
MYSQL_PORT: "{{MYSQL_PORT}}"
MYSQL_DATABASE: {{MYSQL_DATABASE}}
MYSQL_USER: {{MYSQL_USER}}
MYSQL_PASSWORD_FILE: /run/secrets/{{ABBREV}}-cms-db-password
CMS_SERVER_NAME: {{CMS_SERVER_NAME}}
CMS_SMTP_SERVER: {{SMTP_SERVER}}
CMS_SMTP_USERNAME: {{SMTP_USERNAME}}
CMS_SMTP_PASSWORD: {{SMTP_PASSWORD}}
CMS_SMTP_USE_TLS: {{SMTP_USE_TLS}}
CMS_SMTP_USE_STARTTLS: {{SMTP_USE_STARTTLS}}
CMS_SMTP_REWRITE_DOMAIN: {{SMTP_REWRITE_DOMAIN}}
CMS_SMTP_HOSTNAME: {{SMTP_HOSTNAME}}
CMS_SMTP_FROM_LINE_OVERRIDE: {{SMTP_FROM_LINE_OVERRIDE}}
CMS_PHP_POST_MAX_SIZE: {{PHP_POST_MAX_SIZE}}
CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}}
CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}"
secrets:
- {{ABBREV}}-cms-db-password
volumes:
- {{ABBREV}}-cms-custom:/var/www/cms/custom
- {{ABBREV}}-cms-backup:/var/www/backup
- {{THEME_HOST_PATH}}:/var/www/cms/web/theme/custom
- {{ABBREV}}-cms-library:/var/www/cms/library
- {{ABBREV}}-cms-userscripts:/var/www/cms/web/userscripts
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
ports:
- "{{HOST_HTTP_PORT}}:80"
networks:
{{ABBREV}}-net:
aliases:
- web
deploy:
restart_policy:
condition: any
resources:
limits:
memory: 1G
{{ABBREV}}-memcached:
image: {{MEMCACHED_IMAGE}}
command: [memcached, -m, "15"]
networks:
{{ABBREV}}-net:
aliases:
- memcached
deploy:
restart_policy:
condition: any
resources:
limits:
memory: 100M
{{ABBREV}}-quickchart:
image: {{QUICKCHART_IMAGE}}
networks:
{{ABBREV}}-net:
aliases:
- quickchart
deploy:
restart_policy:
condition: any
{{ABBREV}}-newt:
image: {{NEWT_IMAGE}}
environment:
PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}}
NEWT_ID: {{NEWT_ID}}
NEWT_SECRET: {{NEWT_SECRET}}
networks:
{{ABBREV}}-net: {}
deploy:
restart_policy:
condition: any
networks:
{{ABBREV}}-net:
driver: overlay
attachable: false
volumes:
{{ABBREV}}-cms-custom:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-custom
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-backup:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-backup
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-library:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-library
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-userscripts:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-userscripts
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-ca-certs:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-ca-certs
o: {{CIFS_OPTS}}
secrets:
{{ABBREV}}-cms-db-password:
external: true