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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,12 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Generates a Docker Compose v3.9 YAML for a Xibo CMS stack.
/// Combined format: no separate config.env, no MySQL container (external DB),
/// CIFS volumes, Newt tunnel service, and inline environment variables.
/// Renders a Docker Compose file by loading a template from the git repo and substituting
/// all {{PLACEHOLDER}} tokens with values from RenderContext.
/// The template file expected in the repo is <c>template.yml</c>.
/// Call <see cref="GetTemplateYaml"/> to obtain the canonical template to commit to your repo.
/// </summary>
public class ComposeRenderService
{
@@ -22,278 +17,220 @@ public class ComposeRenderService
_logger = logger;
}
public string Render(RenderContext ctx)
/// <summary>
/// Substitutes all {{PLACEHOLDER}} tokens in <paramref name="templateYaml"/> and returns
/// the final compose YAML ready for deployment.
/// </summary>
public string Render(string templateYaml, RenderContext ctx)
{
_logger.LogInformation("Rendering Compose for stack: {StackName}", ctx.StackName);
_logger.LogInformation("Rendering Compose for stack {StackName} from template", ctx.StackName);
var root = new YamlMappingNode();
if (string.IsNullOrWhiteSpace(templateYaml))
throw new ArgumentException("Template YAML is empty. Ensure template.yml exists in the configured git repository.");
// Version
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
var cifsOpts = BuildCifsOpts(ctx);
// Comment — customer name (added as a YAML comment isn't natively supported,
// so we prepend it manually after serialization)
BuildServices(root, ctx);
BuildNetworks(root, ctx);
BuildVolumes(root, ctx);
BuildSecrets(root, ctx);
var doc = new YamlDocument(root);
var stream = new YamlStream(doc);
using var writer = new StringWriter();
stream.Save(writer, assignAnchors: false);
var output = writer.ToString()
.Replace("...\n", "").Replace("...", "");
// Prepend customer name comment
output = $"# Customer: {ctx.CustomerName}\n{output}";
_logger.LogDebug("Compose rendered for {StackName}: {ServiceCount} services",
ctx.StackName, 4);
return output;
return templateYaml
.Replace("{{ABBREV}}", ctx.CustomerAbbrev)
.Replace("{{CUSTOMER_NAME}}", ctx.CustomerName)
.Replace("{{STACK_NAME}}", ctx.StackName)
.Replace("{{CMS_SERVER_NAME}}", ctx.CmsServerName)
.Replace("{{HOST_HTTP_PORT}}", ctx.HostHttpPort.ToString())
.Replace("{{CMS_IMAGE}}", ctx.CmsImage)
.Replace("{{MEMCACHED_IMAGE}}", ctx.MemcachedImage)
.Replace("{{QUICKCHART_IMAGE}}", ctx.QuickChartImage)
.Replace("{{NEWT_IMAGE}}", ctx.NewtImage)
.Replace("{{THEME_HOST_PATH}}", ctx.ThemeHostPath)
.Replace("{{MYSQL_HOST}}", ctx.MySqlHost)
.Replace("{{MYSQL_PORT}}", ctx.MySqlPort)
.Replace("{{MYSQL_DATABASE}}", ctx.MySqlDatabase)
.Replace("{{MYSQL_USER}}", ctx.MySqlUser)
.Replace("{{SMTP_SERVER}}", ctx.SmtpServer)
.Replace("{{SMTP_USERNAME}}", ctx.SmtpUsername)
.Replace("{{SMTP_PASSWORD}}", ctx.SmtpPassword)
.Replace("{{SMTP_USE_TLS}}", ctx.SmtpUseTls)
.Replace("{{SMTP_USE_STARTTLS}}", ctx.SmtpUseStartTls)
.Replace("{{SMTP_REWRITE_DOMAIN}}", ctx.SmtpRewriteDomain)
.Replace("{{SMTP_HOSTNAME}}", ctx.SmtpHostname)
.Replace("{{SMTP_FROM_LINE_OVERRIDE}}", ctx.SmtpFromLineOverride)
.Replace("{{PHP_POST_MAX_SIZE}}", ctx.PhpPostMaxSize)
.Replace("{{PHP_UPLOAD_MAX_FILESIZE}}", ctx.PhpUploadMaxFilesize)
.Replace("{{PHP_MAX_EXECUTION_TIME}}", ctx.PhpMaxExecutionTime)
.Replace("{{PANGOLIN_ENDPOINT}}", ctx.PangolinEndpoint)
.Replace("{{NEWT_ID}}", ctx.NewtId ?? "CONFIGURE_ME")
.Replace("{{NEWT_SECRET}}", ctx.NewtSecret ?? "CONFIGURE_ME")
.Replace("{{CIFS_SERVER}}", (ctx.CifsServer ?? string.Empty).TrimEnd('/'))
.Replace("{{CIFS_SHARE_NAME}}", BuildSharePath(ctx.CifsShareName, ctx.CifsShareFolder))
// Legacy token — was a path component (e.g. "/sharename"), so templates concatenate
// it directly after the server: //{{CIFS_SERVER}}{{CIFS_SHARE_BASE_PATH}}/...
// We must keep the leading "/" to produce a valid device path.
.Replace("{{CIFS_SHARE_BASE_PATH}}", "/" + BuildSharePath(ctx.CifsShareName, ctx.CifsShareFolder))
.Replace("{{CIFS_USERNAME}}", ctx.CifsUsername ?? string.Empty)
.Replace("{{CIFS_PASSWORD}}", ctx.CifsPassword ?? string.Empty)
.Replace("{{CIFS_OPTS}}", cifsOpts);
}
// ── Services ────────────────────────────────────────────────────────────
private void BuildServices(YamlMappingNode root, RenderContext ctx)
private static string BuildCifsOpts(RenderContext ctx)
{
var services = new YamlMappingNode();
root.Children[new YamlScalarNode("services")] = services;
if (string.IsNullOrWhiteSpace(ctx.CifsServer))
return string.Empty;
BuildWebService(services, ctx);
BuildMemcachedService(services, ctx);
BuildQuickChartService(services, ctx);
if (ctx.IncludeNewt)
BuildNewtService(services, ctx);
// vers=3.0 is required by most modern SMB servers (e.g. Hetzner Storage Box).
// Without it, mount.cifs may negotiate SMBv1/2.1 which gets rejected as "permission denied".
var opts = $"addr={ctx.CifsServer},username={ctx.CifsUsername},password={ctx.CifsPassword},vers=3.0";
if (!string.IsNullOrWhiteSpace(ctx.CifsExtraOptions))
opts += $",{ctx.CifsExtraOptions}";
return opts;
}
private void BuildWebService(YamlMappingNode services, RenderContext ctx)
/// <summary>
/// Combines share name and optional subfolder into a single path segment.
/// e.g. ("u548897-sub1", "ots_cms") → "u548897-sub1/ots_cms"
/// ("u548897-sub1", null) → "u548897-sub1"
/// </summary>
private static string BuildSharePath(string? shareName, string? shareFolder)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-web")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.CmsImage);
// Environment — all config.env values merged inline
var env = new YamlMappingNode
{
{ "CMS_USE_MEMCACHED", "true" },
{ "MEMCACHED_HOST", "memcached" },
{ "MYSQL_HOST", ctx.MySqlHost },
{ "MYSQL_PORT", ctx.MySqlPort },
{ "MYSQL_DATABASE", ctx.MySqlDatabase },
{ "MYSQL_USER", ctx.MySqlUser },
{ "MYSQL_PASSWORD_FILE", $"/run/secrets/{ctx.CustomerAbbrev}-cms-db-password" },
{ "CMS_SMTP_SERVER", ctx.SmtpServer },
{ "CMS_SMTP_USERNAME", ctx.SmtpUsername },
{ "CMS_SMTP_PASSWORD", ctx.SmtpPassword },
{ "CMS_SMTP_USE_TLS", ctx.SmtpUseTls },
{ "CMS_SMTP_USE_STARTTLS", ctx.SmtpUseStartTls },
{ "CMS_SMTP_REWRITE_DOMAIN", ctx.SmtpRewriteDomain },
{ "CMS_SMTP_HOSTNAME", ctx.SmtpHostname },
{ "CMS_SMTP_FROM_LINE_OVERRIDE", ctx.SmtpFromLineOverride },
{ "CMS_SERVER_NAME", ctx.CmsServerName },
{ "CMS_PHP_POST_MAX_SIZE", ctx.PhpPostMaxSize },
{ "CMS_PHP_UPLOAD_MAX_FILESIZE", ctx.PhpUploadMaxFilesize },
{ "CMS_PHP_MAX_EXECUTION_TIME", ctx.PhpMaxExecutionTime },
};
svc.Children[new YamlScalarNode("environment")] = env;
// Secrets
var secrets = new YamlSequenceNode(
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-db-password")
);
svc.Children[new YamlScalarNode("secrets")] = secrets;
// Volumes
var volumes = new YamlSequenceNode(
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-custom:/var/www/cms/custom"),
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-backup:/var/www/backup"),
new YamlScalarNode($"{ctx.ThemeHostPath}:/var/www/cms/web/theme/custom"),
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-library:/var/www/cms/library"),
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-userscripts:/var/www/cms/web/userscripts"),
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-ca-certs:/var/www/cms/ca-certs")
);
svc.Children[new YamlScalarNode("volumes")] = volumes;
// Ports
var ports = new YamlSequenceNode(
new YamlScalarNode($"{ctx.HostHttpPort}:80")
);
svc.Children[new YamlScalarNode("ports")] = ports;
// Networks
var webNet = new YamlMappingNode
{
{ "aliases", new YamlSequenceNode(new YamlScalarNode("web")) }
};
var networks = new YamlMappingNode();
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = webNet;
svc.Children[new YamlScalarNode("networks")] = networks;
// Deploy
var deploy = new YamlMappingNode
{
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
{ "resources", new YamlMappingNode
{ { "limits", new YamlMappingNode { { "memory", "1G" } } } }
}
};
svc.Children[new YamlScalarNode("deploy")] = deploy;
var name = (shareName ?? string.Empty).Trim('/');
var folder = (shareFolder ?? string.Empty).Trim('/');
return string.IsNullOrEmpty(folder) ? name : $"{name}/{folder}";
}
private void BuildMemcachedService(YamlMappingNode services, RenderContext ctx)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-memcached")] = svc;
/// <summary>
/// Returns the canonical <c>template.yml</c> content with all placeholders.
/// Commit this file to the root of your template git repository.
/// </summary>
public static string GetTemplateYaml() => TemplateYaml;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.MemcachedImage);
// ── Canonical template ──────────────────────────────────────────────────
var command = new YamlSequenceNode(
new YamlScalarNode("memcached"),
new YamlScalarNode("-m"),
new YamlScalarNode("15")
);
svc.Children[new YamlScalarNode("command")] = command;
public const string TemplateYaml =
"""
# Customer: {{CUSTOMER_NAME}}
version: "3.9"
var mcNet = new YamlMappingNode
{
{ "aliases", new YamlSequenceNode(new YamlScalarNode("memcached")) }
};
var networks = new YamlMappingNode();
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = mcNet;
svc.Children[new YamlScalarNode("networks")] = networks;
services:
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
{
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
{ "resources", new YamlMappingNode
{ { "limits", new YamlMappingNode { { "memory", "100M" } } } }
}
};
}
{{ABBREV}}-web:
image: {{CMS_IMAGE}}
environment:
CMS_USE_MEMCACHED: "true"
MEMCACHED_HOST: memcached
MYSQL_HOST: {{MYSQL_HOST}}
MYSQL_PORT: "{{MYSQL_PORT}}"
MYSQL_DATABASE: {{MYSQL_DATABASE}}
MYSQL_USER: {{MYSQL_USER}}
MYSQL_PASSWORD_FILE: /run/secrets/{{ABBREV}}-cms-db-password
CMS_SERVER_NAME: {{CMS_SERVER_NAME}}
CMS_SMTP_SERVER: {{SMTP_SERVER}}
CMS_SMTP_USERNAME: {{SMTP_USERNAME}}
CMS_SMTP_PASSWORD: {{SMTP_PASSWORD}}
CMS_SMTP_USE_TLS: {{SMTP_USE_TLS}}
CMS_SMTP_USE_STARTTLS: {{SMTP_USE_STARTTLS}}
CMS_SMTP_REWRITE_DOMAIN: {{SMTP_REWRITE_DOMAIN}}
CMS_SMTP_HOSTNAME: {{SMTP_HOSTNAME}}
CMS_SMTP_FROM_LINE_OVERRIDE: {{SMTP_FROM_LINE_OVERRIDE}}
CMS_PHP_POST_MAX_SIZE: {{PHP_POST_MAX_SIZE}}
CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}}
CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}"
secrets:
- {{ABBREV}}-cms-db-password
volumes:
- {{ABBREV}}-cms-custom:/var/www/cms/custom
- {{ABBREV}}-cms-backup:/var/www/backup
- {{THEME_HOST_PATH}}:/var/www/cms/web/theme/custom
- {{ABBREV}}-cms-library:/var/www/cms/library
- {{ABBREV}}-cms-userscripts:/var/www/cms/web/userscripts
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
ports:
- "{{HOST_HTTP_PORT}}:80"
networks:
{{ABBREV}}-net:
aliases:
- web
deploy:
restart_policy:
condition: any
resources:
limits:
memory: 1G
private void BuildQuickChartService(YamlMappingNode services, RenderContext ctx)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-quickchart")] = svc;
{{ABBREV}}-memcached:
image: {{MEMCACHED_IMAGE}}
command: [memcached, -m, "15"]
networks:
{{ABBREV}}-net:
aliases:
- memcached
deploy:
restart_policy:
condition: any
resources:
limits:
memory: 100M
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.QuickChartImage);
{{ABBREV}}-quickchart:
image: {{QUICKCHART_IMAGE}}
networks:
{{ABBREV}}-net:
aliases:
- quickchart
deploy:
restart_policy:
condition: any
var qcNet = new YamlMappingNode
{
{ "aliases", new YamlSequenceNode(new YamlScalarNode("quickchart")) }
};
var networks = new YamlMappingNode();
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = qcNet;
svc.Children[new YamlScalarNode("networks")] = networks;
{{ABBREV}}-newt:
image: {{NEWT_IMAGE}}
environment:
PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}}
NEWT_ID: {{NEWT_ID}}
NEWT_SECRET: {{NEWT_SECRET}}
networks:
{{ABBREV}}-net: {}
deploy:
restart_policy:
condition: any
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
{
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
};
}
networks:
{{ABBREV}}-net:
driver: overlay
attachable: "false"
private void BuildNewtService(YamlMappingNode services, RenderContext ctx)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-newt")] = svc;
volumes:
{{ABBREV}}-cms-custom:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-custom
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-backup:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-backup
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-library:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-library
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-userscripts:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-userscripts
o: {{CIFS_OPTS}}
{{ABBREV}}-cms-ca-certs:
driver: local
driver_opts:
type: cifs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-ca-certs
o: {{CIFS_OPTS}}
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.NewtImage);
var env = new YamlMappingNode
{
{ "PANGOLIN_ENDPOINT", ctx.PangolinEndpoint },
{ "NEWT_ID", ctx.NewtId ?? "CONFIGURE_ME" },
{ "NEWT_SECRET", ctx.NewtSecret ?? "CONFIGURE_ME" },
};
svc.Children[new YamlScalarNode("environment")] = env;
var networks = new YamlMappingNode();
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = new YamlMappingNode();
svc.Children[new YamlScalarNode("networks")] = networks;
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
{
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
};
}
// ── Networks ────────────────────────────────────────────────────────────
private void BuildNetworks(YamlMappingNode root, RenderContext ctx)
{
var netDef = new YamlMappingNode
{
{ "driver", "overlay" },
{ "attachable", "false" }
};
var networks = new YamlMappingNode();
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = netDef;
root.Children[new YamlScalarNode("networks")] = networks;
}
// ── Volumes (CIFS) ──────────────────────────────────────────────────────
private void BuildVolumes(YamlMappingNode root, RenderContext ctx)
{
var volumes = new YamlMappingNode();
root.Children[new YamlScalarNode("volumes")] = volumes;
var volumeNames = new[]
{
$"{ctx.CustomerAbbrev}-cms-custom",
$"{ctx.CustomerAbbrev}-cms-backup",
$"{ctx.CustomerAbbrev}-cms-library",
$"{ctx.CustomerAbbrev}-cms-userscripts",
$"{ctx.CustomerAbbrev}-cms-ca-certs",
};
foreach (var volName in volumeNames)
{
if (ctx.UseCifsVolumes && !string.IsNullOrWhiteSpace(ctx.CifsServer))
{
var device = $"//{ctx.CifsServer}{ctx.CifsShareBasePath}/{volName}";
var opts = $"addr={ctx.CifsServer},username={ctx.CifsUsername},password={ctx.CifsPassword}";
if (!string.IsNullOrWhiteSpace(ctx.CifsExtraOptions))
opts += $",{ctx.CifsExtraOptions}";
var volDef = new YamlMappingNode
{
{ "driver", "local" },
{ "driver_opts", new YamlMappingNode
{
{ "type", "cifs" },
{ "device", device },
{ "o", opts }
}
}
};
volumes.Children[new YamlScalarNode(volName)] = volDef;
}
else
{
volumes.Children[new YamlScalarNode(volName)] = new YamlMappingNode();
}
}
}
// ── Secrets ─────────────────────────────────────────────────────────────
private void BuildSecrets(YamlMappingNode root, RenderContext ctx)
{
var secrets = new YamlMappingNode();
root.Children[new YamlScalarNode("secrets")] = secrets;
foreach (var secretName in ctx.SecretNames)
{
secrets.Children[new YamlScalarNode(secretName)] =
new YamlMappingNode { { "external", "true" } };
}
}
secrets:
{{ABBREV}}-cms-db-password:
external: true
""";
}
/// <summary>Context object with all inputs needed to render a Compose file.</summary>
@@ -336,26 +273,16 @@ public class RenderContext
public string PhpMaxExecutionTime { get; set; } = "600";
// Pangolin / Newt
public bool IncludeNewt { get; set; } = true;
public string PangolinEndpoint { get; set; } = "https://app.pangolin.net";
public string? NewtId { get; set; }
public string? NewtSecret { get; set; }
// CIFS volume settings
public bool UseCifsVolumes { get; set; }
public string? CifsServer { get; set; }
public string? CifsShareBasePath { get; set; }
public string? CifsShareName { get; set; }
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Empty/null = share root.</summary>
public string? CifsShareFolder { get; set; }
public string? CifsUsername { get; set; }
public string? CifsPassword { get; set; }
public string? CifsExtraOptions { get; set; }
// Secrets to declare as external
public List<string> SecretNames { get; set; } = new();
// Legacy — kept for backward compat but no longer used
public string TemplateYaml { get; set; } = string.Empty;
public Dictionary<string, string> TemplateEnvValues { get; set; } = new();
public List<string> TemplateEnvLines { get; set; } = new();
public List<string> Constraints { get; set; } = new();
public string LibraryHostPath { get; set; } = string.Empty;
}

View File

@@ -47,22 +47,15 @@ public class GitTemplateService
});
var yamlPath = FindFile(cacheDir, "template.yml");
var envPath = FindFile(cacheDir, "template.env");
if (yamlPath == null)
throw new FileNotFoundException("template.yml not found in repository root.");
if (envPath == null)
throw new FileNotFoundException("template.env not found in repository root.");
throw new FileNotFoundException("template.yml not found in repository root. Commit the template file produced by ComposeRenderService.GetTemplateYaml() to the repo root.");
var yaml = await File.ReadAllTextAsync(yamlPath);
var envLines = (await File.ReadAllLinesAsync(envPath))
.Where(l => !string.IsNullOrWhiteSpace(l) && !l.TrimStart().StartsWith('#'))
.ToList();
return new TemplateConfig
{
Yaml = yaml,
EnvLines = envLines,
FetchedAt = DateTime.UtcNow
};
}

View File

@@ -12,6 +12,30 @@ public interface IDockerCliService
Task<DeploymentResultDto> RemoveStackAsync(string stackName);
Task<List<StackInfo>> ListStacksAsync();
Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName);
/// <summary>Ensures a directory exists on the target host (equivalent to mkdir -p).</summary>
Task<bool> EnsureDirectoryAsync(string path);
/// <summary>
/// Ensures the required folders exist on an SMB/CIFS share, creating any that are missing.
/// If <paramref name="cifsShareFolder"/> is non-empty, creates it first as a subfolder of the share,
/// then creates the volume folders inside it.
/// Uses smbclient on the remote host to interact with the share without requiring a mount.
/// </summary>
Task<bool> EnsureSmbFoldersAsync(
string cifsServer,
string cifsShareName,
string cifsUsername,
string cifsPassword,
IEnumerable<string> folderNames,
string? cifsShareFolder = null);
/// <summary>
/// Removes all Docker volumes whose names start with <paramref name="stackName"/>_.
/// Volumes currently in use by running containers will be skipped.
/// Safe for CIFS volumes since data lives on the remote share, not in the local volume.
/// </summary>
Task<bool> RemoveStackVolumesAsync(string stackName);
}
public class StackInfo

View File

@@ -4,6 +4,7 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MySqlConnector;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.DTOs;
@@ -72,21 +73,33 @@ public class InstanceService
{
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
// ── Check uniqueness ────────────────────────────────────────────
// ── Check uniqueness — redirect to update if already present ───
var existing = await _db.CmsInstances.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
if (existing != null)
throw new InvalidOperationException($"Stack '{stackName}' already exists.");
{
_logger.LogInformation("Instance '{StackName}' already exists in DB — applying stack update instead.", stackName);
var updateDto = new UpdateInstanceDto
{
CifsServer = dto.CifsServer,
CifsShareName = dto.CifsShareName,
CifsShareFolder = dto.CifsShareFolder,
CifsUsername = dto.CifsUsername,
CifsPassword = dto.CifsPassword,
CifsExtraOptions = dto.CifsExtraOptions,
};
return await UpdateInstanceAsync(existing.Id, updateDto, userId);
}
// ── 1. Clone template repo (optional) ───────────────────────────
// ── 1. Clone / refresh template repo ───────────────────────────
var repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat);
if (!string.IsNullOrWhiteSpace(repoUrl))
{
_logger.LogInformation("Cloning template repo: {RepoUrl}", repoUrl);
await _git.FetchAsync(repoUrl, repoPat);
}
if (string.IsNullOrWhiteSpace(repoUrl))
throw new InvalidOperationException("Git template repository URL is not configured. Set it in Settings → Git Repo URL.");
_logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl);
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
// ── 2. Generate MySQL password → Docker Swarm secret ────────────
var mysqlPassword = GenerateRandomPassword(32);
@@ -116,7 +129,8 @@ public class InstanceService
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
var cifsServer = dto.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
var cifsShareBasePath = dto.CifsShareBasePath ?? await _settings.GetAsync(SettingsService.CifsShareBasePath);
var cifsShareName = dto.CifsShareName ?? await _settings.GetAsync(SettingsService.CifsShareName);
var cifsShareFolder = dto.CifsShareFolder ?? await _settings.GetAsync(SettingsService.CifsShareFolder);
var cifsUsername = dto.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
var cifsOptions = dto.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
@@ -130,7 +144,7 @@ public class InstanceService
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// ── 4. Render compose YAML ──────────────────────────────────────
// ── 4. Render compose YAML from template ────────────────────────
var renderCtx = new RenderContext
{
CustomerName = dto.CustomerName,
@@ -158,20 +172,21 @@ public class InstanceService
PhpPostMaxSize = phpPostMaxSize,
PhpUploadMaxFilesize = phpUploadMaxFilesize,
PhpMaxExecutionTime = phpMaxExecutionTime,
IncludeNewt = true,
PangolinEndpoint = pangolinEndpoint,
NewtId = dto.NewtId,
NewtSecret = dto.NewtSecret,
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath,
CifsShareName = cifsShareName,
CifsShareFolder = cifsShareFolder,
CifsUsername = cifsUsername,
CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions,
SecretNames = new List<string> { mysqlSecretName },
};
var composeYaml = _compose.Render(renderCtx);
_logger.LogInformation("CIFS render values: server={CifsServer}, share={CifsShareName}, folder={CifsShareFolder}, user={CifsUsername}",
cifsServer, cifsShareName, cifsShareFolder, cifsUsername);
var composeYaml = _compose.Render(templateConfig.Yaml, renderCtx);
if (_dockerOptions.ValidateBeforeDeploy)
{
@@ -180,12 +195,35 @@ public class InstanceService
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
}
// ── 5. Deploy stack ─────────────────────────────────────────────
// ── 5. Ensure bind-mount directories exist on the remote host ───
if (!string.IsNullOrWhiteSpace(themePath))
await _docker.EnsureDirectoryAsync(themePath);
// ── 5b. Ensure SMB share folders exist ───────────────────────────
if (!string.IsNullOrWhiteSpace(cifsServer) && !string.IsNullOrWhiteSpace(cifsShareName))
{
var smbFolders = new[]
{
$"{abbrev}-cms-custom",
$"{abbrev}-cms-backup",
$"{abbrev}-cms-library",
$"{abbrev}-cms-userscripts",
$"{abbrev}-cms-ca-certs",
};
_logger.LogInformation("Ensuring SMB share folders exist on //{Server}/{Share}", cifsServer, cifsShareName);
await _docker.EnsureSmbFoldersAsync(cifsServer, cifsShareName, cifsUsername ?? string.Empty, cifsPassword ?? string.Empty, smbFolders, cifsShareFolder);
}
// ── 6. Remove stale CIFS volumes so Docker recreates them with current settings ─
_logger.LogInformation("Removing stale CIFS volumes for stack {StackName} to ensure fresh mount options", stackName);
await _docker.RemoveStackVolumesAsync(stackName);
// ── 7. Deploy stack ─────────────────────────────────────────────
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
if (!deployResult.Success)
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
// ── 6. Record instance ──────────────────────────────────────────
// ── 8. Record instance ──────────────────────────────────────────
var instance = new CmsInstance
{
CustomerName = dto.CustomerName,
@@ -202,7 +240,8 @@ public class InstanceService
Status = InstanceStatus.Active,
SshHostId = dto.SshHostId,
CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath,
CifsShareName = cifsShareName,
CifsShareFolder = cifsShareFolder,
CifsUsername = cifsUsername,
CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions,
@@ -220,7 +259,7 @@ public class InstanceService
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
stackName, instance.Id, sw.ElapsedMilliseconds);
deployResult.ServiceCount = renderCtx.IncludeNewt ? 4 : 3;
deployResult.ServiceCount = 4;
deployResult.Message = "Instance deployed successfully.";
return deployResult;
}
@@ -238,13 +277,13 @@ public class InstanceService
}
/// <summary>
/// Creates MySQL database and user on external MySQL server via SSH.
/// Called by the ViewModel before CreateInstanceAsync since it needs SSH access.
/// Creates MySQL database and user on the external MySQL server using a direct TCP connection.
/// Admin credentials are sourced from SettingsService (MySql.AdminUser / MySql.AdminPassword).
/// The new user's password is passed in and never logged.
/// </summary>
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
string abbrev,
string mysqlPassword,
Func<string, Task<(int ExitCode, string Stdout, string Stderr)>> runSshCommand)
string mysqlPassword)
{
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
@@ -254,29 +293,65 @@ public class InstanceService
var dbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
var userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
var safePwd = mySqlAdminPassword.Replace("'", "'\\''");
var safeUserPwd = mysqlPassword.Replace("'", "'\\''");
_logger.LogInformation("Creating MySQL database {Db} and user {User} via direct TCP", dbName, userName);
var sql = $"CREATE DATABASE IF NOT EXISTS \\`{dbName}\\`; "
+ $"CREATE USER IF NOT EXISTS '{userName}'@'%' IDENTIFIED BY '{safeUserPwd}'; "
+ $"GRANT ALL PRIVILEGES ON \\`{dbName}\\`.* TO '{userName}'@'%'; "
+ $"FLUSH PRIVILEGES;";
if (!int.TryParse(mySqlPort, out var port))
port = 3306;
var cmd = $"mysql -h {mySqlHost} -P {mySqlPort} -u {mySqlAdminUser} -p'{safePwd}' -e \"{sql}\" 2>&1";
_logger.LogInformation("Creating MySQL database {Db} and user {User}", dbName, userName);
var (exitCode, stdout, stderr) = await runSshCommand(cmd);
if (exitCode == 0)
var csb = new MySqlConnectionStringBuilder
{
_logger.LogInformation("MySQL database and user created: {Db} / {User}", dbName, userName);
Server = mySqlHost,
Port = (uint)port,
UserID = mySqlAdminUser,
Password = mySqlAdminPassword,
ConnectionTimeout = 15,
SslMode = MySqlSslMode.Preferred,
};
try
{
await using var connection = new MySqlConnection(csb.ConnectionString);
await connection.OpenAsync();
// Backtick-escape database name and single-quote-escape username to handle
// any special characters in names. The new user password is passed as a
// parameter so it is never interpolated into SQL text.
var escapedDb = dbName.Replace("`", "``");
var escapedUser = userName.Replace("'", "''");
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText = $"CREATE DATABASE IF NOT EXISTS `{escapedDb}`";
await cmd.ExecuteNonQueryAsync();
}
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText = $"CREATE USER IF NOT EXISTS '{escapedUser}'@'%' IDENTIFIED BY @pwd";
cmd.Parameters.AddWithValue("@pwd", mysqlPassword);
await cmd.ExecuteNonQueryAsync();
}
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText = $"GRANT ALL PRIVILEGES ON `{escapedDb}`.* TO '{escapedUser}'@'%'";
await cmd.ExecuteNonQueryAsync();
}
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText = "FLUSH PRIVILEGES";
await cmd.ExecuteNonQueryAsync();
}
_logger.LogInformation("MySQL database {Db} and user {User} created successfully", dbName, userName);
return (true, $"Database '{dbName}' and user '{userName}' created.");
}
var error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
_logger.LogError("MySQL setup failed: {Error}", error);
return (false, $"MySQL setup failed: {error.Trim()}");
catch (MySqlException ex)
{
_logger.LogError(ex, "MySQL setup failed for database {Db}", dbName);
return (false, $"MySQL setup failed: {ex.Message}");
}
}
public async Task<DeploymentResultDto> UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null)
@@ -299,7 +374,8 @@ public class InstanceService
if (dto.XiboUsername != null) instance.XiboUsername = dto.XiboUsername;
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
if (dto.CifsServer != null) instance.CifsServer = dto.CifsServer;
if (dto.CifsShareBasePath != null) instance.CifsShareBasePath = dto.CifsShareBasePath;
if (dto.CifsShareName != null) instance.CifsShareName = dto.CifsShareName;
if (dto.CifsShareFolder != null) instance.CifsShareFolder = dto.CifsShareFolder;
if (dto.CifsUsername != null) instance.CifsUsername = dto.CifsUsername;
if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword;
if (dto.CifsExtraOptions != null) instance.CifsExtraOptions = dto.CifsExtraOptions;
@@ -324,12 +400,13 @@ public class InstanceService
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
// Use per-instance CIFS credentials
var cifsServer = instance.CifsServer;
var cifsShareBasePath = instance.CifsShareBasePath;
var cifsUsername = instance.CifsUsername;
var cifsPassword = instance.CifsPassword;
var cifsOptions = instance.CifsExtraOptions ?? "file_mode=0777,dir_mode=0777";
// Use per-instance CIFS credentials, falling back to global settings
var cifsServer = instance.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
var cifsShareName = instance.CifsShareName ?? await _settings.GetAsync(SettingsService.CifsShareName);
var cifsShareFolder = instance.CifsShareFolder ?? await _settings.GetAsync(SettingsService.CifsShareFolder);
var cifsUsername = instance.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
var cifsPassword = instance.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
var cifsOptions = instance.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
@@ -340,6 +417,17 @@ public class InstanceService
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// ── Fetch template from git ─────────────────────────────────────
var repoUrl = instance.TemplateRepoUrl;
var repoPat = instance.TemplateRepoPat ?? await _settings.GetAsync(SettingsService.GitRepoPat);
if (string.IsNullOrWhiteSpace(repoUrl))
repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
if (string.IsNullOrWhiteSpace(repoUrl))
throw new InvalidOperationException("Git template repository URL is not configured. Set it in Settings → Git Repo URL.");
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
var renderCtx = new RenderContext
{
CustomerName = instance.CustomerName,
@@ -367,18 +455,19 @@ public class InstanceService
PhpPostMaxSize = phpPostMaxSize,
PhpUploadMaxFilesize = phpUploadMaxFilesize,
PhpMaxExecutionTime = phpMaxExecutionTime,
IncludeNewt = true,
PangolinEndpoint = pangolinEndpoint,
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath,
CifsShareName = cifsShareName,
CifsShareFolder = cifsShareFolder,
CifsUsername = cifsUsername,
CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions,
SecretNames = new List<string> { mysqlSecretName },
};
var composeYaml = _compose.Render(renderCtx);
_logger.LogInformation("CIFS render values (update): server={CifsServer}, share={CifsShareName}, folder={CifsShareFolder}, user={CifsUsername}",
cifsServer, cifsShareName, cifsShareFolder, cifsUsername);
var composeYaml = _compose.Render(templateConfig.Yaml, renderCtx);
if (_dockerOptions.ValidateBeforeDeploy)
{
@@ -387,6 +476,30 @@ public class InstanceService
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
}
// Ensure bind-mount directories exist on the remote host
if (!string.IsNullOrWhiteSpace(instance.ThemeHostPath))
await _docker.EnsureDirectoryAsync(instance.ThemeHostPath);
// Ensure SMB share folders exist
if (!string.IsNullOrWhiteSpace(cifsServer) && !string.IsNullOrWhiteSpace(cifsShareName))
{
var abbrevLower = instance.CustomerAbbrev;
var smbFolders = new[]
{
$"{abbrevLower}-cms-custom",
$"{abbrevLower}-cms-backup",
$"{abbrevLower}-cms-library",
$"{abbrevLower}-cms-userscripts",
$"{abbrevLower}-cms-ca-certs",
};
_logger.LogInformation("Ensuring SMB share folders exist on //{Server}/{Share}", cifsServer, cifsShareName);
await _docker.EnsureSmbFoldersAsync(cifsServer, cifsShareName, cifsUsername ?? string.Empty, cifsPassword ?? string.Empty, smbFolders, cifsShareFolder);
}
// Remove stale CIFS volumes so Docker recreates them with current settings
_logger.LogInformation("Removing stale CIFS volumes for stack {StackName} to ensure fresh mount options", instance.StackName);
await _docker.RemoveStackVolumesAsync(instance.StackName);
var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true);
if (!deployResult.Success)
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");

View File

@@ -51,7 +51,8 @@ public class SettingsService
// CIFS
public const string CifsServer = "Cifs.Server";
public const string CifsShareBasePath = "Cifs.ShareBasePath";
public const string CifsShareName = "Cifs.ShareName";
public const string CifsShareFolder = "Cifs.ShareFolder";
public const string CifsUsername = "Cifs.Username";
public const string CifsPassword = "Cifs.Password";
public const string CifsOptions = "Cifs.Options";

View File

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

View File

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

View File

@@ -151,9 +151,145 @@ public class SshDockerCliService : IDockerCliService
.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()
{
if (_currentHost == null)
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)
[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 _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
@@ -110,7 +111,8 @@ public partial class CreateInstanceViewModel : ObservableObject
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
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;
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
@@ -148,7 +150,6 @@ public partial class CreateInstanceViewModel : ObservableObject
dockerCli.SetHost(SelectedSshHost);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(SelectedSshHost);
var ssh = _services.GetRequiredService<SshConnectionService>();
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
@@ -161,12 +162,11 @@ public partial class CreateInstanceViewModel : ObservableObject
SetProgress(20, "Generating secrets...");
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...");
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
Abbrev,
mysqlPassword,
cmd => ssh.RunCommandAsync(SelectedSshHost, cmd));
mysqlPassword);
AppendOutput($"[MySQL] {mysqlMsg}");
if (!mysqlOk)
@@ -195,7 +195,8 @@ public partial class CreateInstanceViewModel : ObservableObject
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.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(),
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.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.Input;
using Microsoft.Extensions.DependencyInjection;
using MySqlConnector;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
@@ -42,7 +43,8 @@ public partial class SettingsViewModel : ObservableObject
// ── CIFS ────────────────────────────────────────────────────────────────
[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 _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
@@ -100,7 +102,8 @@ public partial class SettingsViewModel : ObservableObject
// CIFS
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);
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
@@ -166,7 +169,8 @@ public partial class SettingsViewModel : ObservableObject
// CIFS
(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.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
@@ -208,30 +212,34 @@ public partial class SettingsViewModel : ObservableObject
}
IsBusy = true;
StatusMessage = "Testing MySQL connection via SSH...";
StatusMessage = "Testing MySQL connection...";
try
{
// The test runs a mysql --version or a simple SELECT 1 query via SSH
// We need an SshHost to route through — use the first available
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OTSSignsOrchestrator.Core.Data.XiboContext>();
var host = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
.FirstOrDefaultAsync(db.SshHosts);
if (!int.TryParse(MySqlPort, out var port))
port = 3306;
if (host == null)
var csb = new MySqlConnectionStringBuilder
{
StatusMessage = "No SSH hosts configured. Add one in the Hosts page first.";
return;
}
Server = MySqlHost,
Port = (uint)port,
UserID = MySqlAdminUser,
Password = MySqlAdminPassword,
ConnectionTimeout = 10,
SslMode = MySqlSslMode.Preferred,
};
var ssh = _services.GetRequiredService<Services.SshConnectionService>();
var port = int.TryParse(MySqlPort, out var p) ? p : 3306;
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);
await using var connection = new MySqlConnection(csb.ConnectionString);
await connection.OpenAsync();
StatusMessage = exitCode == 0
? $"MySQL connection successful via {host.Label}."
: $"MySQL connection failed: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}";
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT 1";
await cmd.ExecuteScalarAsync();
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port}).";
}
catch (MySqlException ex)
{
StatusMessage = $"MySQL connection failed: {ex.Message}";
}
catch (Exception ex)
{

View File

@@ -54,8 +54,11 @@
<TextBlock Text="CIFS Server" FontSize="12" />
<TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" />
<TextBlock Text="Share Base Path" FontSize="12" />
<TextBox Text="{Binding CifsShareBasePath}" Watermark="e.g. /share/cms" />
<TextBlock Text="Share Name" FontSize="12" />
<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" />
<TextBox Text="{Binding CifsUsername}" Watermark="SMB username" />

View File

@@ -127,8 +127,11 @@
<TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" />
<TextBox Text="{Binding CifsServer}" Watermark="nas.local" />
<TextBlock Text="Share Base Path" FontSize="12" />
<TextBox Text="{Binding CifsShareBasePath}" Watermark="/share/cms" />
<TextBlock Text="Share Name" FontSize="12" />
<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" />
<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