From 4a903bfd2a4be087037756bec715e9f93e2311b0 Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Wed, 18 Feb 2026 16:15:54 -0500 Subject: [PATCH] Remove appsettings.json, rename CifsShareBasePath to CifsShareName, add CifsShareFolder to CmsInstances, and create a template.yml for Docker configuration. --- .template-cache/2dc03e2b2b45fef3 | 1 + ...RenameShareBasePathToShareName.Designer.cs | 343 +++++++++++ ...18180240_RenameShareBasePathToShareName.cs | 28 + ...60218202617_AddCifsShareFolder.Designer.cs | 347 +++++++++++ .../20260218202617_AddCifsShareFolder.cs | 29 + .../Migrations/XiboContextModelSnapshot.cs | 6 +- .../Models/DTOs/CreateInstanceDto.cs | 6 +- .../Models/DTOs/TemplateConfig.cs | 1 - .../Models/DTOs/UpdateInstanceDto.cs | 6 +- .../Models/Entities/CmsInstance.cs | 6 +- .../OTSSignsOrchestrator.Core.csproj | 1 + .../Services/ComposeRenderService.cs | 469 +++++++-------- .../Services/GitTemplateService.cs | 9 +- .../Services/IDockerCliService.cs | 24 + .../Services/InstanceService.cs | 219 +++++-- .../Services/SettingsService.cs | 3 +- OTSSignsOrchestrator.Desktop/App.axaml.cs | 10 +- .../OTSSignsOrchestrator.Desktop.csproj | 1 + .../Services/SshDockerCliService.cs | 136 +++++ .../ViewModels/CreateInstanceViewModel.cs | 15 +- .../ViewModels/SettingsViewModel.cs | 50 +- .../Views/CreateInstanceView.axaml | 7 +- .../Views/SettingsView.axaml | 7 +- .../Components/Pages/CreateInstance.razor | 459 --------------- .../Configuration/AppOptions.cs | 116 ---- .../Configuration/DependencyInjection.cs | 173 ------ .../Models/DTOs/CreateInstanceDto.cs | 36 -- .../Models/Entities/CmsInstance.cs | 99 ---- .../Services/ComposeRenderService.cs | 394 ------------- .../Services/InstanceService.cs | 551 ------------------ OTSSignsOrchestrator/appsettings.json | 86 --- template.yml | 125 ++++ 32 files changed, 1474 insertions(+), 2289 deletions(-) create mode 160000 .template-cache/2dc03e2b2b45fef3 create mode 100644 OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.Designer.cs create mode 100644 OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.cs create mode 100644 OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.Designer.cs create mode 100644 OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.cs delete mode 100644 OTSSignsOrchestrator/Components/Pages/CreateInstance.razor delete mode 100644 OTSSignsOrchestrator/Configuration/AppOptions.cs delete mode 100644 OTSSignsOrchestrator/Configuration/DependencyInjection.cs delete mode 100644 OTSSignsOrchestrator/Models/DTOs/CreateInstanceDto.cs delete mode 100644 OTSSignsOrchestrator/Models/Entities/CmsInstance.cs delete mode 100644 OTSSignsOrchestrator/Services/ComposeRenderService.cs delete mode 100644 OTSSignsOrchestrator/Services/InstanceService.cs delete mode 100644 OTSSignsOrchestrator/appsettings.json create mode 100644 template.yml diff --git a/.template-cache/2dc03e2b2b45fef3 b/.template-cache/2dc03e2b2b45fef3 new file mode 160000 index 0000000..07ab87b --- /dev/null +++ b/.template-cache/2dc03e2b2b45fef3 @@ -0,0 +1 @@ +Subproject commit 07ab87bc65fa52879cadb8a18de60a7c51ac6d78 diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.Designer.cs new file mode 100644 index 0000000..69c2455 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.Designer.cs @@ -0,0 +1,343 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsSensitive") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.HasIndex("Category"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CifsExtraOptions") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CifsPassword") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CifsServer") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CifsShareName") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CifsUsername") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CmsServerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Constraints") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerAbbrev") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("TEXT"); + + b.Property("CustomerName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("HostHttpPort") + .HasColumnType("INTEGER"); + + b.Property("LibraryHostPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SmtpServer") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SmtpUsername") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SshHostId") + .HasColumnType("TEXT"); + + b.Property("StackName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TemplateCacheKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TemplateLastFetch") + .HasColumnType("TEXT"); + + b.Property("TemplateRepoPat") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TemplateRepoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ThemeHostPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("XiboApiTestStatus") + .HasColumnType("INTEGER"); + + b.Property("XiboApiTestedAt") + .HasColumnType("TEXT"); + + b.Property("XiboPassword") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Operation") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsGlobal") + .HasColumnType("INTEGER"); + + b.Property("LastRotatedAt") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("KeyPassphrase") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastTestSuccess") + .HasColumnType("INTEGER"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UseKeyAuth") + .HasColumnType("INTEGER"); + + b.Property("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 + } + } +} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.cs b/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.cs new file mode 100644 index 0000000..9320ae1 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OTSSignsOrchestrator.Core.Migrations +{ + /// + public partial class RenameShareBasePathToShareName : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "CifsShareBasePath", + table: "CmsInstances", + newName: "CifsShareName"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "CifsShareName", + table: "CmsInstances", + newName: "CifsShareBasePath"); + } + } +} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.Designer.cs new file mode 100644 index 0000000..a125238 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.Designer.cs @@ -0,0 +1,347 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsSensitive") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.HasIndex("Category"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CifsExtraOptions") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CifsPassword") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CifsServer") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CifsShareFolder") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CifsShareName") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CifsUsername") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CmsServerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Constraints") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerAbbrev") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("TEXT"); + + b.Property("CustomerName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("HostHttpPort") + .HasColumnType("INTEGER"); + + b.Property("LibraryHostPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SmtpServer") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SmtpUsername") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SshHostId") + .HasColumnType("TEXT"); + + b.Property("StackName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TemplateCacheKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TemplateLastFetch") + .HasColumnType("TEXT"); + + b.Property("TemplateRepoPat") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TemplateRepoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ThemeHostPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("XiboApiTestStatus") + .HasColumnType("INTEGER"); + + b.Property("XiboApiTestedAt") + .HasColumnType("TEXT"); + + b.Property("XiboPassword") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Operation") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsGlobal") + .HasColumnType("INTEGER"); + + b.Property("LastRotatedAt") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("KeyPassphrase") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastTestSuccess") + .HasColumnType("INTEGER"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UseKeyAuth") + .HasColumnType("INTEGER"); + + b.Property("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 + } + } +} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.cs b/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.cs new file mode 100644 index 0000000..f612631 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OTSSignsOrchestrator.Core.Migrations +{ + /// + public partial class AddCifsShareFolder : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CifsShareFolder", + table: "CmsInstances", + type: "TEXT", + maxLength: 500, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CifsShareFolder", + table: "CmsInstances"); + } + } +} diff --git a/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs b/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs index 951a05f..3230a26 100644 --- a/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs +++ b/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs @@ -63,7 +63,11 @@ namespace OTSSignsOrchestrator.Core.Migrations .HasMaxLength(200) .HasColumnType("TEXT"); - b.Property("CifsShareBasePath") + b.Property("CifsShareFolder") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CifsShareName") .HasMaxLength(500) .HasColumnType("TEXT"); diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs b/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs index 80622a6..6275bdf 100644 --- a/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs +++ b/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs @@ -28,7 +28,11 @@ public class CreateInstanceDto public string? CifsServer { get; set; } [MaxLength(500)] - public string? CifsShareBasePath { get; set; } + public string? CifsShareName { get; set; } + + /// Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root. + [MaxLength(500)] + public string? CifsShareFolder { get; set; } [MaxLength(200)] public string? CifsUsername { get; set; } diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/TemplateConfig.cs b/OTSSignsOrchestrator.Core/Models/DTOs/TemplateConfig.cs index 0a495c4..7075819 100644 --- a/OTSSignsOrchestrator.Core/Models/DTOs/TemplateConfig.cs +++ b/OTSSignsOrchestrator.Core/Models/DTOs/TemplateConfig.cs @@ -3,6 +3,5 @@ namespace OTSSignsOrchestrator.Core.Models.DTOs; public class TemplateConfig { public string Yaml { get; set; } = string.Empty; - public List EnvLines { get; set; } = new(); public DateTime FetchedAt { get; set; } = DateTime.UtcNow; } diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/UpdateInstanceDto.cs b/OTSSignsOrchestrator.Core/Models/DTOs/UpdateInstanceDto.cs index ce16d79..9b9adca 100644 --- a/OTSSignsOrchestrator.Core/Models/DTOs/UpdateInstanceDto.cs +++ b/OTSSignsOrchestrator.Core/Models/DTOs/UpdateInstanceDto.cs @@ -30,7 +30,11 @@ public class UpdateInstanceDto public string? CifsServer { get; set; } [MaxLength(500)] - public string? CifsShareBasePath { get; set; } + public string? CifsShareName { get; set; } + + /// Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root. + [MaxLength(500)] + public string? CifsShareFolder { get; set; } [MaxLength(200)] public string? CifsUsername { get; set; } diff --git a/OTSSignsOrchestrator.Core/Models/Entities/CmsInstance.cs b/OTSSignsOrchestrator.Core/Models/Entities/CmsInstance.cs index db55f4d..c542492 100644 --- a/OTSSignsOrchestrator.Core/Models/Entities/CmsInstance.cs +++ b/OTSSignsOrchestrator.Core/Models/Entities/CmsInstance.cs @@ -95,7 +95,11 @@ public class CmsInstance public string? CifsServer { get; set; } [MaxLength(500)] - public string? CifsShareBasePath { get; set; } + public string? CifsShareName { get; set; } + + /// Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root. + [MaxLength(500)] + public string? CifsShareFolder { get; set; } [MaxLength(200)] public string? CifsUsername { get; set; } diff --git a/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj b/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj index 365539e..e09659b 100644 --- a/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj +++ b/OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj @@ -17,6 +17,7 @@ + diff --git a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs index bb7dcf7..5038147 100644 --- a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs +++ b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs @@ -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; /// -/// 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 template.yml. +/// Call to obtain the canonical template to commit to your repo. /// public class ComposeRenderService { @@ -22,278 +17,220 @@ public class ComposeRenderService _logger = logger; } - public string Render(RenderContext ctx) + /// + /// Substitutes all {{PLACEHOLDER}} tokens in and returns + /// the final compose YAML ready for deployment. + /// + 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) + /// + /// 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" + /// + 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; + /// + /// Returns the canonical template.yml content with all placeholders. + /// Commit this file to the root of your template git repository. + /// + 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 + """; } /// Context object with all inputs needed to render a Compose file. @@ -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; } + /// Optional subfolder within the share (e.g. "ots_cms"). Empty/null = share root. + 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 SecretNames { get; set; } = new(); - - // Legacy — kept for backward compat but no longer used - public string TemplateYaml { get; set; } = string.Empty; - public Dictionary TemplateEnvValues { get; set; } = new(); - public List TemplateEnvLines { get; set; } = new(); - public List Constraints { get; set; } = new(); - public string LibraryHostPath { get; set; } = string.Empty; } diff --git a/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs b/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs index 6e51fd9..48fd031 100644 --- a/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs +++ b/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs @@ -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 }; } diff --git a/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs b/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs index 3681cee..f7afa18 100644 --- a/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs +++ b/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs @@ -12,6 +12,30 @@ public interface IDockerCliService Task RemoveStackAsync(string stackName); Task> ListStacksAsync(); Task> InspectStackServicesAsync(string stackName); + + /// Ensures a directory exists on the target host (equivalent to mkdir -p). + Task EnsureDirectoryAsync(string path); + + /// + /// Ensures the required folders exist on an SMB/CIFS share, creating any that are missing. + /// If 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. + /// + Task EnsureSmbFoldersAsync( + string cifsServer, + string cifsShareName, + string cifsUsername, + string cifsPassword, + IEnumerable folderNames, + string? cifsShareFolder = null); + + /// + /// Removes all Docker volumes whose names start with _. + /// 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. + /// + Task RemoveStackVolumesAsync(string stackName); } public class StackInfo diff --git a/OTSSignsOrchestrator.Core/Services/InstanceService.cs b/OTSSignsOrchestrator.Core/Services/InstanceService.cs index fe19fbf..5ebf62e 100644 --- a/OTSSignsOrchestrator.Core/Services/InstanceService.cs +++ b/OTSSignsOrchestrator.Core/Services/InstanceService.cs @@ -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 { 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 } /// - /// 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. /// public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync( string abbrev, - string mysqlPassword, - Func> 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 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 { 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}"); diff --git a/OTSSignsOrchestrator.Core/Services/SettingsService.cs b/OTSSignsOrchestrator.Core/Services/SettingsService.cs index b51a46b..acd9806 100644 --- a/OTSSignsOrchestrator.Core/Services/SettingsService.cs +++ b/OTSSignsOrchestrator.Core/Services/SettingsService.cs @@ -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"; diff --git a/OTSSignsOrchestrator.Desktop/App.axaml.cs b/OTSSignsOrchestrator.Desktop/App.axaml.cs index fcd4336..a9565ba 100644 --- a/OTSSignsOrchestrator.Desktop/App.axaml.cs +++ b/OTSSignsOrchestrator.Desktop/App.axaml.cs @@ -118,11 +118,11 @@ public class App : Application // SSH services (singletons — maintain connections) services.AddSingleton(); - // Docker services via SSH (scoped so they get fresh per-operation context) - services.AddTransient(); - services.AddTransient(); - services.AddTransient(sp => sp.GetRequiredService()); - services.AddTransient(sp => sp.GetRequiredService()); + // Docker services via SSH (singletons — SetHost() must persist across scopes) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); // Core services services.AddTransient(); diff --git a/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj b/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj index 1ec304f..261f50e 100644 --- a/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj +++ b/OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj @@ -25,6 +25,7 @@ + diff --git a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs index ebe5874..e3183f2 100644 --- a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs +++ b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs @@ -151,9 +151,145 @@ public class SshDockerCliService : IDockerCliService .ToList(); } + public async Task 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 EnsureSmbFoldersAsync( + string cifsServer, + string cifsShareName, + string cifsUsername, + string cifsPassword, + IEnumerable 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 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; + } } diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs index 849aef0..f13a145 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs @@ -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(); 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(); dockerSecrets.SetHost(SelectedSshHost); - var ssh = _services.GetRequiredService(); using var scope = _services.CreateScope(); var instanceSvc = scope.ServiceProvider.GetRequiredService(); @@ -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(), diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs index a521454..987ab11 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs @@ -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(); - 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(); - 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) { diff --git a/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml b/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml index fb39f10..191a5a9 100644 --- a/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml +++ b/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml @@ -54,8 +54,11 @@ - - + + + + + diff --git a/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml b/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml index 464f80e..833accd 100644 --- a/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml +++ b/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml @@ -127,8 +127,11 @@ - - + + + + + diff --git a/OTSSignsOrchestrator/Components/Pages/CreateInstance.razor b/OTSSignsOrchestrator/Components/Pages/CreateInstance.razor deleted file mode 100644 index a58602e..0000000 --- a/OTSSignsOrchestrator/Components/Pages/CreateInstance.razor +++ /dev/null @@ -1,459 +0,0 @@ -@page "/instances/create" -@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")] -@inject InstanceService InstanceSvc -@inject NavigationManager Navigation -@inject IOptions Defaults -@using Microsoft.Extensions.Options -@using OTSSignsOrchestrator.Configuration - -Create Instance - OTS Signs Orchestrator - -

New CMS Instance

- -
- @* ── Left column: form ── *@ -
- - - - -
- - @* ── Customer ── *@ -
- - -
Display name — stored as a comment in the stack file.
-
- -
- - -
3 letters used as a prefix for every stack resource.
-
- - @* ── Optional overrides ── *@ -
- Advanced overrides -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
-
- -
- - Cancel -
- -
-
- - @if (!string.IsNullOrEmpty(resultMessage)) - { -
- @resultMessage - @if (resultSuccess && createdInstanceId.HasValue) - { - View Instance → - } -
- } -
- - @* ── Right column: live preview ── *@ -
-
-
- Resource Preview - @if (AbbrevIsValid) - { - @Abbrev - } - else - { - enter abbreviation - } -
-
- - @* Stack *@ - - - -
- - @* Services *@ -
Services
- - - - - -
- - @* Volumes *@ -
CIFS Volumes
- @foreach (var vol in new[] { "cms-custom", "cms-backup", "cms-library", "cms-userscripts", "cms-ca-certs" }) - { - - } - - -
- - @* Docker secret *@ -
Docker Secret
- - -
- - @* External config *@ -
External Resources
- - - - - - @if (!string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl)) - { -
-
Template
- - } - else - { -
- ⚠ No template repo configured. Set it in Settings before deploying. -
- } -
-
-
-
- -@* 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) => - @
- @Label - @Value - @if (!string.IsNullOrEmpty(Note)) - { - @Note - } -
; -} - - -Create Instance - OTS Signs Orchestrator - -

Create CMS Instance

- -
-
- - - - -
-
-
Customer Details
-
-
- - -
Full display name — stored as a comment in the stack file.
-
-
- -
- - - Stack: @(model.CustomerAbbrev?.ToLowerInvariant() ?? "…") - -
-
- Exactly 3 letters (a–z). Used as the prefix for all stack resources: - services, volumes, and network names. -
-
-
-
- - @* Show what will be auto-configured from settings *@ -
-
Auto-configured from Settings
-
-
-
CMS server:
@(Defaults.Value.CmsServerNameTemplate.Replace("{abbrev}", AbbrevPreview))
-
Theme path:
@Defaults.Value.ThemeHostPath
-
MySQL DB:
@(Defaults.Value.MySqlDatabaseTemplate.Replace("{abbrev}", AbbrevPreview))
-
MySQL user:
@(Defaults.Value.MySqlUserTemplate.Replace("{abbrev}", AbbrevPreview))
-
SMTP server:
@Defaults.Value.SmtpServer
-
Template repo:
@(string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl) ? "⚠️ Not configured" : Defaults.Value.TemplateRepoUrl)
-
- Edit defaults in Settings -
-
- - @* Optional overrides *@ -
-
Optional Overrides
-
-
- - -
-
- - -
-
- - -
-
-
- - @* Xibo Credentials (optional) *@ -
-
Xibo API Credentials (optional)
-
-

Provide credentials to enable API connectivity testing after deploy.

-
-
- - -
-
- - -
-
-
-
- -
- - Cancel -
-
-
- - @if (!string.IsNullOrEmpty(resultMessage)) - { -
- @resultMessage - @if (resultSuccess && createdInstanceId.HasValue) - { - View Details - } -
- } -
-
- -@code { - private CreateInstanceDto model = new(); - - private string? constraintsText; - private bool deploying; - private string? resultMessage; - private bool resultSuccess; - private Guid? createdInstanceId; - - /// Live abbrev preview (lowercase, 3 chars max) for the settings preview card. - 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; - } - } -} diff --git a/OTSSignsOrchestrator/Configuration/AppOptions.cs b/OTSSignsOrchestrator/Configuration/AppOptions.cs deleted file mode 100644 index ce70176..0000000 --- a/OTSSignsOrchestrator/Configuration/AppOptions.cs +++ /dev/null @@ -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 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 -} - -/// -/// 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. -/// -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; - /// If true, treat TLS/cert errors as non-fatal (useful for self-signed certs in dev). - public bool AllowInsecureTls { get; set; } = false; -} - -/// -/// 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. -/// -public class CifsOptions -{ - public const string SectionName = "Cifs"; - /// UNC-style device path, e.g. //fileserver.local/xibo-data - public string Device { get; set; } = string.Empty; - /// Hostname/IP of the CIFS server for the addr= mount option. - 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"; -} - -/// -/// Defaults sourced from the Settings page, used to pre-populate or complete instance creation -/// without requiring the operator to retype them every time. -/// -public class InstanceDefaultsOptions -{ - public const string SectionName = "InstanceDefaults"; - /// Default Git template repo URL (operator can override per-instance). - public string TemplateRepoUrl { get; set; } = string.Empty; - public string? TemplateRepoPat { get; set; } - /// Template for CMS_SERVER_NAME. Use {abbrev} as placeholder, e.g. "{abbrev}x.ots-signs.com". - 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; - /// Base host HTTP port; each new instance auto-increments from this value. - public int BaseHostHttpPort { get; set; } = 8080; - /// Template for the theme host path. Use {abbrev} as placeholder. - /// Static host path for the theme volume mount. Overridable per-instance. - public string ThemeHostPath { get; set; } = "/cms/ots-theme"; - /// Template for the library CIFS volume sub-path. Use {abbrev} as placeholder. - public string LibraryShareSubPath { get; set; } = "{abbrev}-cms-library"; - /// MySQL database name template. Use {abbrev}. - public string MySqlDatabaseTemplate { get; set; } = "{abbrev}_cms_db"; - /// MySQL username template. Use {abbrev}. - public string MySqlUserTemplate { get; set; } = "{abbrev}_cms"; -} diff --git a/OTSSignsOrchestrator/Configuration/DependencyInjection.cs b/OTSSignsOrchestrator/Configuration/DependencyInjection.cs deleted file mode 100644 index ec50678..0000000 --- a/OTSSignsOrchestrator/Configuration/DependencyInjection.cs +++ /dev/null @@ -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(config.GetSection(FileLoggingOptions.SectionName)); - builder.Services.Configure(config.GetSection(AuthenticationOptions.SectionName)); - builder.Services.Configure(config.GetSection(GitOptions.SectionName)); - builder.Services.Configure(config.GetSection(DockerOptions.SectionName)); - builder.Services.Configure(config.GetSection(XiboOptions.SectionName)); - builder.Services.Configure(config.GetSection(DatabaseOptions.SectionName)); - builder.Services.Configure(config.GetSection(MySqlAdminOptions.SectionName)); - builder.Services.Configure(config.GetSection(CifsOptions.SectionName)); - builder.Services.Configure(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("Database:Provider") ?? "Sqlite"; - var connStr = config.GetConnectionString("Default") ?? "Data Source=xibo-admin.db"; - - builder.Services.AddDbContext(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(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // --- 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(); - 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( - 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; - } -} diff --git a/OTSSignsOrchestrator/Models/DTOs/CreateInstanceDto.cs b/OTSSignsOrchestrator/Models/DTOs/CreateInstanceDto.cs deleted file mode 100644 index 84f3719..0000000 --- a/OTSSignsOrchestrator/Models/DTOs/CreateInstanceDto.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace OTSSignsOrchestrator.Models.DTOs; - -public class CreateInstanceDto -{ - /// Full display name of the customer (stored as YAML comment). - [Required, MaxLength(100)] - public string CustomerName { get; set; } = string.Empty; - - /// 3-letter uppercase abbreviation used as prefix in all stack resource names. - [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; } - - /// Override the theme host path from settings (e.g. /cms/ots-theme). - [MaxLength(500)] - public string? ThemeHostPath { get; set; } - - /// Comma-separated placement constraints. - public List? Constraints { get; set; } - - [MaxLength(200)] - public string? XiboUsername { get; set; } - - [MaxLength(200)] - public string? XiboPassword { get; set; } -} diff --git a/OTSSignsOrchestrator/Models/Entities/CmsInstance.cs b/OTSSignsOrchestrator/Models/Entities/CmsInstance.cs deleted file mode 100644 index 446e96c..0000000 --- a/OTSSignsOrchestrator/Models/Entities/CmsInstance.cs +++ /dev/null @@ -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; - - /// 3-letter lowercase abbreviation used as stack resource prefix (e.g. "ots"). - [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; - - /// - /// JSON array of placement constraints, e.g. ["node.labels.xibo==true"] - /// - [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; - - /// - /// Encrypted Xibo admin username for API access. - /// - [MaxLength(500)] - public string? XiboUsername { get; set; } - - /// - /// Encrypted Xibo admin password for API access. - /// Never logged; encrypted at rest via Data Protection. - /// - [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; - - /// Soft delete marker. - public DateTime? DeletedAt { get; set; } - - // Navigation properties - public ICollection OperationLogs { get; set; } = new List(); -} diff --git a/OTSSignsOrchestrator/Services/ComposeRenderService.cs b/OTSSignsOrchestrator/Services/ComposeRenderService.cs deleted file mode 100644 index dab26e6..0000000 --- a/OTSSignsOrchestrator/Services/ComposeRenderService.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public class ComposeRenderService -{ - private readonly XiboOptions _xiboOptions; - private readonly DockerOptions _dockerOptions; - private readonly CifsOptions _cifsOptions; - private readonly ILogger _logger; - - public ComposeRenderService( - IOptions xiboOptions, - IOptions dockerOptions, - IOptions cifsOptions, - ILogger logger) - { - _xiboOptions = xiboOptions.Value; - _dockerOptions = dockerOptions.Value; - _cifsOptions = cifsOptions.Value; - _logger = logger; - } - - /// - /// Render a final Compose YAML from the template + user inputs + secrets. - /// - 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); - } - - /// - /// Build the environment mapping for cms-web: start with template.env values, - /// then apply all required orchestrator overrides. - /// - private YamlMappingNode BuildEnvFromTemplate(Dictionary 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(); - } - - /// - /// Build a CIFS-driver volume node matching the pattern: - /// - /// 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" - /// - /// The credentials= path points to a credentials file pre-deployed on the target host. - /// - 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; - } -} - -/// -/// All inputs needed to render a single Compose file. -/// -public class RenderContext -{ - public string CustomerName { get; set; } = string.Empty; - /// 3-letter abbreviation used as naming prefix (e.g. "ots"). - 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; - - /// - /// 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. - /// - public string CifsCredentialsFilePath { get; set; } = "/etc/docker-cifs-credentials"; - - public string TemplateYaml { get; set; } = string.Empty; - /// Parsed key/value pairs from template.env (placeholder-substituted). - public Dictionary TemplateEnvValues { get; set; } = new(); - - public List Constraints { get; set; } = new(); - /// Secret names to declare as external in the compose file. - public List SecretNames { get; set; } = new(); -} diff --git a/OTSSignsOrchestrator/Services/InstanceService.cs b/OTSSignsOrchestrator/Services/InstanceService.cs deleted file mode 100644 index 289c30c..0000000 --- a/OTSSignsOrchestrator/Services/InstanceService.cs +++ /dev/null @@ -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; - -/// -/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect). -/// Coordinates GitTemplateService, ComposeRenderService, ComposeValidationService, -/// DockerCliService, DockerSecretsService, MySqlProvisionService, and XiboApiService. -/// -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 _logger; - - public InstanceService( - XiboContext db, - GitTemplateService git, - ComposeRenderService compose, - ComposeValidationService validation, - DockerCliService docker, - DockerSecretsService secrets, - MySqlProvisionService mysql, - XiboApiService xibo, - IOptions dockerOptions, - IOptions mysqlAdminOptions, - IOptions cifsOptions, - IOptions instanceDefaults, - ILogger 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; - } - - /// - /// 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) - /// - public async Task 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 { 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; - } - } - - /// - /// Update and redeploy an existing CMS instance. - /// - public async Task 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>(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 { 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; - } - } - - /// - /// Delete a CMS instance (soft delete in DB; removes stack from Swarm). - /// - public async Task 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; - } - } - - /// - /// Get an instance by ID. - /// - public async Task GetInstanceAsync(Guid id) - { - return await _db.CmsInstances.FindAsync(id); - } - - /// - /// List all active instances with optional filter. - /// - public async Task<(List 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); - } - - /// - /// Test the Xibo API connection for an instance. - /// - public async Task 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); - } - - /// - /// Parse template.env lines into a key/value dictionary. - /// Replaces {abbrev} placeholders with the customer abbreviation. - /// Skips empty lines and comments. - /// - private static Dictionary ParseEnvLines(IEnumerable lines, string abbrev) - { - var result = new Dictionary(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; - } - - /// Extracts the domain part from an email address, e.g. "user@ots-signs.com" → "ots-signs.com". - private static string ExtractDomain(string email) - { - var atIdx = email?.IndexOf('@') ?? -1; - return atIdx >= 0 ? email![(atIdx + 1)..] : string.Empty; - } -} diff --git a/OTSSignsOrchestrator/appsettings.json b/OTSSignsOrchestrator/appsettings.json deleted file mode 100644 index 92a4044..0000000 --- a/OTSSignsOrchestrator/appsettings.json +++ /dev/null @@ -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": "*" -} diff --git a/template.yml b/template.yml new file mode 100644 index 0000000..fb6da5b --- /dev/null +++ b/template.yml @@ -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