diff --git a/.template-cache/2dc03e2b2b45fef3 b/.template-cache/2dc03e2b2b45fef3 index 07ab87b..292fbb4 160000 --- a/.template-cache/2dc03e2b2b45fef3 +++ b/.template-cache/2dc03e2b2b45fef3 @@ -1 +1 @@ -Subproject commit 07ab87bc65fa52879cadb8a18de60a7c51ac6d78 +Subproject commit 292fbb4bfe873e760b8dd27a70acc072cbe98dc9 diff --git a/OTSSignsOrchestrator.Core/Configuration/AppConstants.cs b/OTSSignsOrchestrator.Core/Configuration/AppConstants.cs index 4af390d..497a94e 100644 --- a/OTSSignsOrchestrator.Core/Configuration/AppConstants.cs +++ b/OTSSignsOrchestrator.Core/Configuration/AppConstants.cs @@ -8,12 +8,34 @@ public static class AppConstants public const string AdminRole = "Admin"; public const string ViewerRole = "Viewer"; + // ── Global Docker secret names ────────────────────────────────────────── + /// Docker secret name for the global SMTP password. public const string GlobalSmtpSecretName = "global_smtp_password"; - /// Build a per-customer MySQL password secret name. - public static string CustomerMysqlSecretName(string customerName) - => $"{SanitizeName(customerName)}_mysql_password"; + /// Docker secret name for the shared MySQL host address. + public const string GlobalMysqlHostSecretName = "global_mysql_host"; + + /// Docker secret name for the shared MySQL port. + public const string GlobalMysqlPortSecretName = "global_mysql_port"; + + // ── Per-instance Docker secret name builders ──────────────────────────── + + /// Build a per-instance MySQL password secret name from the 3-letter abbreviation. + public static string CustomerMysqlPasswordSecretName(string abbrev) + => $"{abbrev}-cms-db-password"; + + /// Build a per-instance MySQL username secret name from the 3-letter abbreviation. + public static string CustomerMysqlUserSecretName(string abbrev) + => $"{abbrev}-cms-db-user"; + + /// Returns all per-instance MySQL secret names for a given abbreviation. + public static string[] AllCustomerMysqlSecretNames(string abbrev) + => new[] + { + CustomerMysqlPasswordSecretName(abbrev), + CustomerMysqlUserSecretName(abbrev), + }; /// Sanitize a customer name for use in Docker/secret names. public static string SanitizeName(string name) diff --git a/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs b/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs index 7935356..e3cb2d4 100644 --- a/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs +++ b/OTSSignsOrchestrator.Core/Configuration/AppOptions.cs @@ -72,5 +72,5 @@ public class InstanceDefaultsOptions public string LibraryShareSubPath { get; set; } = "{abbrev}-cms-library"; public string MySqlDatabaseTemplate { get; set; } = "{abbrev}_cms_db"; - public string MySqlUserTemplate { get; set; } = "{abbrev}_cms"; + public string MySqlUserTemplate { get; set; } = "{abbrev}_cms_user"; } diff --git a/OTSSignsOrchestrator.Core/Data/XiboContext.cs b/OTSSignsOrchestrator.Core/Data/XiboContext.cs index 2137297..f11d3f1 100644 --- a/OTSSignsOrchestrator.Core/Data/XiboContext.cs +++ b/OTSSignsOrchestrator.Core/Data/XiboContext.cs @@ -15,9 +15,7 @@ public class XiboContext : DbContext _dataProtection = dataProtection; } - public DbSet CmsInstances => Set(); public DbSet SshHosts => Set(); - public DbSet SecretMetadata => Set(); public DbSet OperationLogs => Set(); public DbSet AppSettings => Set(); @@ -25,32 +23,6 @@ public class XiboContext : DbContext { base.OnModelCreating(modelBuilder); - // --- CmsInstance --- - modelBuilder.Entity(entity => - { - entity.HasIndex(e => e.StackName).IsUnique(); - entity.HasIndex(e => e.CustomerName); - entity.HasQueryFilter(e => e.DeletedAt == null); - - entity.HasOne(e => e.SshHost) - .WithMany(h => h.Instances) - .HasForeignKey(e => e.SshHostId) - .OnDelete(DeleteBehavior.SetNull); - - if (_dataProtection != null) - { - var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.CmsInstance"); - var pwdConverter = new ValueConverter( - v => v != null ? protector.Protect(v) : null, - v => v != null ? protector.Unprotect(v) : null); - - entity.Property(e => e.XiboPassword).HasConversion(pwdConverter); - entity.Property(e => e.XiboUsername).HasConversion(pwdConverter); - entity.Property(e => e.TemplateRepoPat).HasConversion(pwdConverter); - entity.Property(e => e.CifsPassword).HasConversion(pwdConverter); - } - }); - // --- SshHost --- modelBuilder.Entity(entity => { @@ -71,17 +43,11 @@ public class XiboContext : DbContext } }); - // --- SecretMetadata --- - modelBuilder.Entity(entity => - { - entity.HasIndex(e => e.Name).IsUnique(); - }); - // --- OperationLog --- modelBuilder.Entity(entity => { entity.HasIndex(e => e.Timestamp); - entity.HasIndex(e => e.InstanceId); + entity.HasIndex(e => e.StackName); entity.HasIndex(e => e.Operation); }); diff --git a/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.Designer.cs new file mode 100644 index 0000000..4cca5a8 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.Designer.cs @@ -0,0 +1,339 @@ +// +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("20260219005507_ReplaceCifsWithNfs")] + partial class ReplaceCifsWithNfs + { + /// + 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("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("NfsExport") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("NfsExportFolder") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("NfsExtraOptions") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("NfsServer") + .HasMaxLength(200) + .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/20260219005507_ReplaceCifsWithNfs.cs b/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.cs new file mode 100644 index 0000000..5528f95 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OTSSignsOrchestrator.Core.Migrations +{ + /// + public partial class ReplaceCifsWithNfs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1. Add new NFS columns + migrationBuilder.AddColumn( + name: "NfsServer", + table: "CmsInstances", + type: "TEXT", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "NfsExport", + table: "CmsInstances", + type: "TEXT", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "NfsExportFolder", + table: "CmsInstances", + type: "TEXT", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "NfsExtraOptions", + table: "CmsInstances", + type: "TEXT", + maxLength: 500, + nullable: true); + + // 2. Migrate existing CIFS data into NFS columns + // NfsServer = CifsServer, NfsExport = '/' + CifsShareName, NfsExportFolder = CifsShareFolder + migrationBuilder.Sql( + """ + UPDATE CmsInstances + SET NfsServer = CifsServer, + NfsExport = CASE WHEN CifsShareName IS NOT NULL THEN '/' || CifsShareName ELSE NULL END, + NfsExportFolder = CifsShareFolder, + NfsExtraOptions = CifsExtraOptions + WHERE CifsServer IS NOT NULL; + """); + + // 3. Drop old CIFS columns + migrationBuilder.DropColumn(name: "CifsServer", table: "CmsInstances"); + migrationBuilder.DropColumn(name: "CifsShareName", table: "CmsInstances"); + migrationBuilder.DropColumn(name: "CifsShareFolder", table: "CmsInstances"); + migrationBuilder.DropColumn(name: "CifsUsername", table: "CmsInstances"); + migrationBuilder.DropColumn(name: "CifsPassword", table: "CmsInstances"); + migrationBuilder.DropColumn(name: "CifsExtraOptions", table: "CmsInstances"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Re-add CIFS columns + migrationBuilder.AddColumn( + name: "CifsServer", table: "CmsInstances", type: "TEXT", maxLength: 200, nullable: true); + migrationBuilder.AddColumn( + name: "CifsShareName", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true); + migrationBuilder.AddColumn( + name: "CifsShareFolder", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true); + migrationBuilder.AddColumn( + name: "CifsUsername", table: "CmsInstances", type: "TEXT", maxLength: 200, nullable: true); + migrationBuilder.AddColumn( + name: "CifsPassword", table: "CmsInstances", type: "TEXT", maxLength: 1000, nullable: true); + migrationBuilder.AddColumn( + name: "CifsExtraOptions", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true); + + // Copy NFS data back to CIFS columns + migrationBuilder.Sql( + """ + UPDATE CmsInstances + SET CifsServer = NfsServer, + CifsShareName = CASE WHEN NfsExport IS NOT NULL THEN LTRIM(NfsExport, '/') ELSE NULL END, + CifsShareFolder = NfsExportFolder, + CifsExtraOptions = NfsExtraOptions + WHERE NfsServer IS NOT NULL; + """); + + // Drop NFS columns + migrationBuilder.DropColumn(name: "NfsServer", table: "CmsInstances"); + migrationBuilder.DropColumn(name: "NfsExport", table: "CmsInstances"); + migrationBuilder.DropColumn(name: "NfsExportFolder", table: "CmsInstances"); + migrationBuilder.DropColumn(name: "NfsExtraOptions", table: "CmsInstances"); + } + } +} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.Designer.cs new file mode 100644 index 0000000..b9a66ff --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.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("20260219020727_AddNewtCredentials")] + partial class AddNewtCredentials + { + /// + 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("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("NewtId") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("NewtSecret") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("NfsExport") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("NfsExportFolder") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("NfsExtraOptions") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("NfsServer") + .HasMaxLength(200) + .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/20260219020727_AddNewtCredentials.cs b/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.cs new file mode 100644 index 0000000..ad0d105 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OTSSignsOrchestrator.Core.Migrations +{ + /// + public partial class AddNewtCredentials : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NewtId", + table: "CmsInstances", + type: "TEXT", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "NewtSecret", + table: "CmsInstances", + type: "TEXT", + maxLength: 500, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "NewtId", + table: "CmsInstances"); + + migrationBuilder.DropColumn( + name: "NewtSecret", + table: "CmsInstances"); + } + } +} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.Designer.cs b/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.Designer.cs new file mode 100644 index 0000000..ea5f1fc --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.Designer.cs @@ -0,0 +1,153 @@ +// +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("20260219121529_RemoveCmsInstancesAndSecretMetadata")] + partial class RemoveCmsInstancesAndSecretMetadata + { + /// + 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.OperationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Operation") + .HasColumnType("INTEGER"); + + b.Property("StackName") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Operation"); + + b.HasIndex("StackName"); + + b.HasIndex("Timestamp"); + + b.ToTable("OperationLogs"); + }); + + 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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.cs b/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.cs new file mode 100644 index 0000000..46b147e --- /dev/null +++ b/OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.cs @@ -0,0 +1,159 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OTSSignsOrchestrator.Core.Migrations +{ + /// + public partial class RemoveCmsInstancesAndSecretMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_OperationLogs_CmsInstances_InstanceId", + table: "OperationLogs"); + + migrationBuilder.DropTable( + name: "CmsInstances"); + + migrationBuilder.DropTable( + name: "SecretMetadata"); + + migrationBuilder.DropIndex( + name: "IX_OperationLogs_InstanceId", + table: "OperationLogs"); + + migrationBuilder.DropColumn( + name: "InstanceId", + table: "OperationLogs"); + + migrationBuilder.AddColumn( + name: "StackName", + table: "OperationLogs", + type: "TEXT", + maxLength: 150, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_OperationLogs_StackName", + table: "OperationLogs", + column: "StackName"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_OperationLogs_StackName", + table: "OperationLogs"); + + migrationBuilder.DropColumn( + name: "StackName", + table: "OperationLogs"); + + migrationBuilder.AddColumn( + name: "InstanceId", + table: "OperationLogs", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "CmsInstances", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SshHostId = table.Column(type: "TEXT", nullable: true), + CmsServerName = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Constraints = table.Column(type: "TEXT", maxLength: 2000, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + CustomerAbbrev = table.Column(type: "TEXT", maxLength: 3, nullable: false), + CustomerName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + DeletedAt = table.Column(type: "TEXT", nullable: true), + HostHttpPort = table.Column(type: "INTEGER", nullable: false), + LibraryHostPath = table.Column(type: "TEXT", maxLength: 500, nullable: false), + NewtId = table.Column(type: "TEXT", maxLength: 500, nullable: true), + NewtSecret = table.Column(type: "TEXT", maxLength: 500, nullable: true), + NfsExport = table.Column(type: "TEXT", maxLength: 500, nullable: true), + NfsExportFolder = table.Column(type: "TEXT", maxLength: 500, nullable: true), + NfsExtraOptions = table.Column(type: "TEXT", maxLength: 500, nullable: true), + NfsServer = table.Column(type: "TEXT", maxLength: 200, nullable: true), + SmtpServer = table.Column(type: "TEXT", maxLength: 200, nullable: false), + SmtpUsername = table.Column(type: "TEXT", maxLength: 200, nullable: false), + StackName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + TemplateCacheKey = table.Column(type: "TEXT", maxLength: 100, nullable: true), + TemplateLastFetch = table.Column(type: "TEXT", nullable: true), + TemplateRepoPat = table.Column(type: "TEXT", maxLength: 500, nullable: true), + TemplateRepoUrl = table.Column(type: "TEXT", maxLength: 500, nullable: false), + ThemeHostPath = table.Column(type: "TEXT", maxLength: 500, nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + XiboApiTestStatus = table.Column(type: "INTEGER", nullable: false), + XiboApiTestedAt = table.Column(type: "TEXT", nullable: true), + XiboPassword = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + XiboUsername = table.Column(type: "TEXT", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CmsInstances", x => x.Id); + table.ForeignKey( + name: "FK_CmsInstances_SshHosts_SshHostId", + column: x => x.SshHostId, + principalTable: "SshHosts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "SecretMetadata", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + CustomerName = table.Column(type: "TEXT", maxLength: 100, nullable: true), + IsGlobal = table.Column(type: "INTEGER", nullable: false), + LastRotatedAt = table.Column(type: "TEXT", nullable: true), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SecretMetadata", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_OperationLogs_InstanceId", + table: "OperationLogs", + column: "InstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_CmsInstances_CustomerName", + table: "CmsInstances", + column: "CustomerName"); + + migrationBuilder.CreateIndex( + name: "IX_CmsInstances_SshHostId", + table: "CmsInstances", + column: "SshHostId"); + + migrationBuilder.CreateIndex( + name: "IX_CmsInstances_StackName", + table: "CmsInstances", + column: "StackName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SecretMetadata_Name", + table: "SecretMetadata", + column: "Name", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_OperationLogs_CmsInstances_InstanceId", + table: "OperationLogs", + column: "InstanceId", + principalTable: "CmsInstances", + principalColumn: "Id"); + } + } +} diff --git a/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs b/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs index 3230a26..437aa0e 100644 --- a/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs +++ b/OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs @@ -45,140 +45,6 @@ namespace OTSSignsOrchestrator.Core.Migrations 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") @@ -188,9 +54,6 @@ namespace OTSSignsOrchestrator.Core.Migrations b.Property("DurationMs") .HasColumnType("INTEGER"); - b.Property("InstanceId") - .HasColumnType("TEXT"); - b.Property("Message") .HasMaxLength(2000) .HasColumnType("TEXT"); @@ -198,6 +61,10 @@ namespace OTSSignsOrchestrator.Core.Migrations b.Property("Operation") .HasColumnType("INTEGER"); + b.Property("StackName") + .HasMaxLength(150) + .HasColumnType("TEXT"); + b.Property("Status") .HasColumnType("INTEGER"); @@ -210,47 +77,15 @@ namespace OTSSignsOrchestrator.Core.Migrations b.HasKey("Id"); - b.HasIndex("InstanceId"); - b.HasIndex("Operation"); + b.HasIndex("StackName"); + 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") @@ -309,35 +144,6 @@ namespace OTSSignsOrchestrator.Core.Migrations 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/Models/DTOs/CreateInstanceDto.cs b/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs index 6275bdf..b55e73e 100644 --- a/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs +++ b/OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs @@ -22,24 +22,19 @@ public class CreateInstanceDto [MaxLength(500)] public string? NewtSecret { get; set; } - // ── CIFS / SMB credentials (optional — falls back to global settings) ── + // ── NFS volume settings (optional — falls back to global settings) ── [MaxLength(200)] - public string? CifsServer { get; set; } + public string? NfsServer { get; set; } + + /// NFS export path on the server (e.g. "/srv/nfs"). + [MaxLength(500)] + public string? NfsExport { get; set; } + + /// Optional subfolder within the export (e.g. "ots_cms"). Omit to use the export root. + [MaxLength(500)] + public string? NfsExportFolder { get; set; } [MaxLength(500)] - 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; } - - [MaxLength(500)] - public string? CifsPassword { get; set; } - - [MaxLength(500)] - public string? CifsExtraOptions { get; set; } + public string? NfsExtraOptions { get; set; } } diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/NodeInfo.cs b/OTSSignsOrchestrator.Core/Models/DTOs/NodeInfo.cs new file mode 100644 index 0000000..b37af4d --- /dev/null +++ b/OTSSignsOrchestrator.Core/Models/DTOs/NodeInfo.cs @@ -0,0 +1,16 @@ +namespace OTSSignsOrchestrator.Core.Models.DTOs; + +/// +/// Represents a node in the Docker Swarm cluster, +/// parsed from docker node ls output. +/// +public class NodeInfo +{ + public string Id { get; set; } = string.Empty; + public string Hostname { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Availability { get; set; } = string.Empty; + public string ManagerStatus { get; set; } = string.Empty; + public string EngineVersion { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; +} diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/UpdateInstanceDto.cs b/OTSSignsOrchestrator.Core/Models/DTOs/UpdateInstanceDto.cs index 9b9adca..c9d1136 100644 --- a/OTSSignsOrchestrator.Core/Models/DTOs/UpdateInstanceDto.cs +++ b/OTSSignsOrchestrator.Core/Models/DTOs/UpdateInstanceDto.cs @@ -4,6 +4,32 @@ namespace OTSSignsOrchestrator.Core.Models.DTOs; public class UpdateInstanceDto { + // ── Identity / rendering context (populated from live service inspect) ── + + /// Customer display name (used in log messages and comment header). + [MaxLength(100)] + public string? CustomerName { get; set; } + + /// + /// 3-letter abbreviation. If null, derived automatically from the stack name + /// by stripping the "-cms-stack" suffix. + /// + [MaxLength(3)] + public string? CustomerAbbrev { get; set; } + + /// Public hostname for the CMS (e.g. "acm.ots-signs.com"). + [MaxLength(200)] + public string? CmsServerName { get; set; } + + /// Host-side HTTP port (defaults to 80 when null). + public int? HostHttpPort { get; set; } + + /// Host path bind-mounted as the theme directory. + [MaxLength(500)] + public string? ThemeHostPath { get; set; } + + // ── Optional overrides (null = keep / use global settings) ── + [MaxLength(500)] public string? TemplateRepoUrl { get; set; } @@ -18,30 +44,29 @@ public class UpdateInstanceDto public List? Constraints { get; set; } - [MaxLength(200)] - public string? XiboUsername { get; set; } + // ── NFS volume settings (per-instance) ── [MaxLength(200)] - public string? XiboPassword { get; set; } + public string? NfsServer { get; set; } - // ── CIFS / SMB credentials (per-instance) ── + /// NFS export path on the server (e.g. "/srv/nfs"). + [MaxLength(500)] + public string? NfsExport { get; set; } - [MaxLength(200)] - public string? CifsServer { get; set; } + /// Optional subfolder within the export (e.g. "ots_cms"). Omit to use the export root. + [MaxLength(500)] + public string? NfsExportFolder { get; set; } [MaxLength(500)] - public string? CifsShareName { get; set; } + public string? NfsExtraOptions { get; set; } - /// Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root. + // ── Pangolin / Newt tunnel settings ── + + /// Pangolin Newt ID for the tunnel service. [MaxLength(500)] - public string? CifsShareFolder { get; set; } - - [MaxLength(200)] - public string? CifsUsername { get; set; } + public string? NewtId { get; set; } + /// Pangolin Newt Secret for the tunnel service. [MaxLength(500)] - public string? CifsPassword { get; set; } - - [MaxLength(500)] - public string? CifsExtraOptions { get; set; } + public string? NewtSecret { get; set; } } diff --git a/OTSSignsOrchestrator.Core/Models/Entities/CmsInstance.cs b/OTSSignsOrchestrator.Core/Models/Entities/CmsInstance.cs deleted file mode 100644 index c542492..0000000 --- a/OTSSignsOrchestrator.Core/Models/Entities/CmsInstance.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace OTSSignsOrchestrator.Core.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; - - /// Exactly 3 lowercase letters used to derive all resource names. - [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. Never logged; encrypted at rest. - [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; } - - // ── CIFS / SMB credentials (per-instance) ───────────────────────────── - - [MaxLength(200)] - public string? CifsServer { get; set; } - - [MaxLength(500)] - 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; } - - /// Encrypted CIFS password. Never logged; encrypted at rest. - [MaxLength(1000)] - public string? CifsPassword { get; set; } - - [MaxLength(500)] - public string? CifsExtraOptions { get; set; } - - /// ID of the SshHost this instance is deployed to. - public Guid? SshHostId { get; set; } - - [ForeignKey(nameof(SshHostId))] - public SshHost? SshHost { get; set; } - - public ICollection OperationLogs { get; set; } = new List(); -} diff --git a/OTSSignsOrchestrator.Core/Models/Entities/OperationLog.cs b/OTSSignsOrchestrator.Core/Models/Entities/OperationLog.cs index 300a6ee..2eb1e8e 100644 --- a/OTSSignsOrchestrator.Core/Models/Entities/OperationLog.cs +++ b/OTSSignsOrchestrator.Core/Models/Entities/OperationLog.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; namespace OTSSignsOrchestrator.Core.Models.Entities; @@ -28,10 +27,9 @@ public class OperationLog public OperationType Operation { get; set; } - public Guid? InstanceId { get; set; } - - [ForeignKey(nameof(InstanceId))] - public CmsInstance? Instance { get; set; } + /// Name of the Docker stack this operation relates to (e.g. "acm-cms-stack"). + [MaxLength(150)] + public string? StackName { get; set; } [MaxLength(200)] public string? UserId { get; set; } diff --git a/OTSSignsOrchestrator.Core/Models/Entities/SecretMetadata.cs b/OTSSignsOrchestrator.Core/Models/Entities/SecretMetadata.cs deleted file mode 100644 index 6d1f9f2..0000000 --- a/OTSSignsOrchestrator.Core/Models/Entities/SecretMetadata.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace OTSSignsOrchestrator.Core.Models.Entities; - -public class SecretMetadata -{ - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); - - [Required, MaxLength(200)] - public string Name { get; set; } = string.Empty; - - public bool IsGlobal { get; set; } - - [MaxLength(100)] - public string? CustomerName { get; set; } - - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - public DateTime? LastRotatedAt { get; set; } -} diff --git a/OTSSignsOrchestrator.Core/Models/Entities/SshHost.cs b/OTSSignsOrchestrator.Core/Models/Entities/SshHost.cs index 07dd019..d8275b0 100644 --- a/OTSSignsOrchestrator.Core/Models/Entities/SshHost.cs +++ b/OTSSignsOrchestrator.Core/Models/Entities/SshHost.cs @@ -54,6 +54,4 @@ public class SshHost public DateTime? LastTestedAt { get; set; } public bool? LastTestSuccess { get; set; } - - public ICollection Instances { get; set; } = new List(); } diff --git a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs index 5038147..48b2590 100644 --- a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs +++ b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; namespace OTSSignsOrchestrator.Core.Services; @@ -28,7 +29,8 @@ public class ComposeRenderService if (string.IsNullOrWhiteSpace(templateYaml)) throw new ArgumentException("Template YAML is empty. Ensure template.yml exists in the configured git repository."); - var cifsOpts = BuildCifsOpts(ctx); + var nfsOpts = BuildNfsOpts(ctx); + var nfsDevicePrefix = BuildNfsDevicePrefix(ctx); return templateYaml .Replace("{{ABBREV}}", ctx.CustomerAbbrev) @@ -59,40 +61,87 @@ public class ComposeRenderService .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); + .Replace("{{NFS_DEVICE_PREFIX}}", nfsDevicePrefix) + .Replace("{{NFS_OPTS}}", nfsOpts) + // ── Legacy CIFS token compatibility ───────────────────────────── + // External git template repos may still contain old CIFS tokens. + // Map them to NFS equivalents so those templates render correctly. + .Replace("{{CIFS_SERVER}}", ctx.NfsServer ?? string.Empty) + .Replace("{{CIFS_SHARE_NAME}}", BuildLegacySharePath(ctx)) + .Replace("{{CIFS_SHARE_BASE_PATH}}", "/" + BuildLegacySharePath(ctx)) + .Replace("{{CIFS_USERNAME}}", string.Empty) + .Replace("{{CIFS_PASSWORD}}", string.Empty) + .Replace("{{CIFS_OPTS}}", nfsOpts); } - private static string BuildCifsOpts(RenderContext ctx) + /// + /// Builds a legacy-compatible share path from NFS export + folder for old CIFS templates. + /// Maps NFS export/folder to the path that was previously the CIFS share name/folder. + /// + private static string BuildLegacySharePath(RenderContext ctx) { - if (string.IsNullOrWhiteSpace(ctx.CifsServer)) + var export = (ctx.NfsExport ?? string.Empty).Trim('/'); + var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/'); + return string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}"; + } + + /// + /// Builds the NFS mount options string for Docker volume driver_opts. + /// Format: "addr=<server>,nfsvers=4,proto=tcp[,extraOptions]". + /// + private static string BuildNfsOpts(RenderContext ctx) + { + if (string.IsNullOrWhiteSpace(ctx.NfsServer)) return string.Empty; - // 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}"; + var opts = $"addr={ctx.NfsServer},nfsvers=4,proto=tcp"; + if (!string.IsNullOrWhiteSpace(ctx.NfsExtraOptions)) + opts += $",{ctx.NfsExtraOptions}"; return opts; } /// - /// 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" + /// Builds the NFS device prefix used in volume definitions. + /// Format: ":/export[/subfolder]" (the colon is part of the device path for NFS). + /// e.g. ":/srv/nfs/ots_cms" or ":/srv/nfs". /// - private static string BuildSharePath(string? shareName, string? shareFolder) + private static string BuildNfsDevicePrefix(RenderContext ctx) { - var name = (shareName ?? string.Empty).Trim('/'); - var folder = (shareFolder ?? string.Empty).Trim('/'); - return string.IsNullOrEmpty(folder) ? name : $"{name}/{folder}"; + var export = (ctx.NfsExport ?? string.Empty).Trim('/'); + var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/'); + var path = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}"; + return $":/{path}"; + } + + /// + /// Extracts NFS volume device paths from rendered compose YAML, then strips the + /// NFS export prefix to return just the relative folder paths that need to exist + /// on the NFS server. Works regardless of the template's naming convention + /// (hierarchical ots/cms-custom vs flat ots-cms-custom). + /// + public static List ExtractNfsDeviceFolders(string renderedYaml, string nfsExport, string? nfsExportFolder = null) + { + // NFS device lines look like: device: ":/mnt/Export/folder/ots-cms-custom" + // The colon prefix is the NFS device convention. + var matches = Regex.Matches(renderedYaml, @"device:\s*""?:(/[^""]+)""?", RegexOptions.IgnoreCase); + var export = (nfsExport ?? string.Empty).Trim('/'); + var subFolder = (nfsExportFolder ?? string.Empty).Trim('/'); + var prefix = string.IsNullOrEmpty(subFolder) ? $"/{export}" : $"/{export}/{subFolder}"; + + var folders = new List(); + foreach (Match match in matches) + { + var devicePath = match.Groups[1].Value; // e.g. /mnt/DS-SwarmVolumes/Volumes/ots-cms-custom + if (devicePath.StartsWith(prefix + "/")) + { + // Strip the export prefix to get the relative folder path + var relative = devicePath[(prefix.Length + 1)..]; // e.g. ots-cms-custom or ots/cms-custom + if (!string.IsNullOrEmpty(relative)) + folders.Add(relative); + } + } + + return folders; } /// @@ -134,6 +183,9 @@ public class ComposeRenderService CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}" secrets: - {{ABBREV}}-cms-db-password + - {{ABBREV}}-cms-db-user + - global_mysql_host + - global_mysql_port volumes: - {{ABBREV}}-cms-custom:/var/www/cms/custom - {{ABBREV}}-cms-backup:/var/www/backup @@ -199,37 +251,43 @@ public class ComposeRenderService {{ABBREV}}-cms-custom: driver: local driver_opts: - type: cifs - device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-custom - o: {{CIFS_OPTS}} + type: nfs + device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-custom" + o: "{{NFS_OPTS}}" {{ABBREV}}-cms-backup: driver: local driver_opts: - type: cifs - device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-backup - o: {{CIFS_OPTS}} + type: nfs + device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-backup" + o: "{{NFS_OPTS}}" {{ABBREV}}-cms-library: driver: local driver_opts: - type: cifs - device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-library - o: {{CIFS_OPTS}} + type: nfs + device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-library" + o: "{{NFS_OPTS}}" {{ABBREV}}-cms-userscripts: driver: local driver_opts: - type: cifs - device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-userscripts - o: {{CIFS_OPTS}} + type: nfs + device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-userscripts" + o: "{{NFS_OPTS}}" {{ABBREV}}-cms-ca-certs: driver: local driver_opts: - type: cifs - device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-ca-certs - o: {{CIFS_OPTS}} + type: nfs + device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-ca-certs" + o: "{{NFS_OPTS}}" secrets: {{ABBREV}}-cms-db-password: external: true + {{ABBREV}}-cms-db-user: + external: true + global_mysql_host: + external: true + global_mysql_port: + external: true """; } @@ -277,12 +335,12 @@ public class RenderContext public string? NewtId { get; set; } public string? NewtSecret { get; set; } - // CIFS volume settings - public string? CifsServer { 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; } + // NFS volume settings + public string? NfsServer { get; set; } + /// NFS export path on the server (e.g. "/srv/nfs" or "/export/data"). + public string? NfsExport { get; set; } + /// Optional subfolder within the export (e.g. "ots_cms"). Empty/null = export root. + public string? NfsExportFolder { get; set; } + /// Additional NFS mount options appended after the defaults (nfsvers=4,proto=tcp). + public string? NfsExtraOptions { get; set; } } diff --git a/OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs b/OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs index 0cc2676..f7ba170 100644 --- a/OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs +++ b/OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using OTSSignsOrchestrator.Core.Configuration; using YamlDotNet.RepresentationModel; namespace OTSSignsOrchestrator.Core.Services; @@ -91,6 +92,11 @@ public class ComposeValidationService if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secretsNode) { + var presentSecrets = secretsNode.Children.Keys + .OfType() + .Select(k => k.Value!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in secretsNode.Children) { if (value is YamlMappingNode secretNode) @@ -99,6 +105,23 @@ public class ComposeValidationService warnings.Add($"Secret '{(key as YamlScalarNode)?.Value}' is not external."); } } + + // Validate that all required MySQL secrets are declared + if (!string.IsNullOrEmpty(customerAbbrev)) + { + var requiredSecrets = new[] + { + AppConstants.CustomerMysqlPasswordSecretName(customerAbbrev), + AppConstants.CustomerMysqlUserSecretName(customerAbbrev), + AppConstants.GlobalMysqlHostSecretName, + AppConstants.GlobalMysqlPortSecretName, + }; + foreach (var required in requiredSecrets) + { + if (!presentSecrets.Contains(required)) + errors.Add($"Missing required secret: '{required}'."); + } + } } return new ValidationResult { Errors = errors, Warnings = warnings }; diff --git a/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs b/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs index f7afa18..7438ca5 100644 --- a/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs +++ b/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs @@ -1,3 +1,4 @@ +using MySqlConnector; using OTSSignsOrchestrator.Core.Models.DTOs; namespace OTSSignsOrchestrator.Core.Services; @@ -17,25 +18,75 @@ public interface IDockerCliService 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, + /// Ensures the required folders exist on an NFS export, creating any that are missing. + /// If is non-empty, creates it first as a subfolder of the export, /// then creates the volume folders inside it. - /// Uses smbclient on the remote host to interact with the share without requiring a mount. + /// Temporarily mounts the NFS export on the Docker host to create the directories. /// - Task EnsureSmbFoldersAsync( - string cifsServer, - string cifsShareName, - string cifsUsername, - string cifsPassword, + Task EnsureNfsFoldersAsync( + string nfsServer, + string nfsExport, IEnumerable folderNames, - string? cifsShareFolder = null); + string? nfsExportFolder = null); + + /// + /// Same as but returns the error message on failure + /// so callers can surface actionable diagnostics. + /// + Task<(bool Success, string? Error)> EnsureNfsFoldersWithErrorAsync( + string nfsServer, + string nfsExport, + IEnumerable folderNames, + string? nfsExportFolder = 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. + /// Safe for NFS volumes since data lives on the remote export, not in the local volume. /// Task RemoveStackVolumesAsync(string stackName); + + /// + /// Lists all nodes in the Docker Swarm cluster. + /// Must be executed against a Swarm manager node. + /// + Task> ListNodesAsync(); + + /// + /// Force-updates a service so all its tasks are restarted and pick up any changed + /// secrets or config (equivalent to docker service update --force). + /// + Task ForceUpdateServiceAsync(string serviceName); + + /// + /// Opens a to a remote MySQL server through the + /// implementation's transport (e.g. an SSH tunnel). The caller must dispose + /// both the connection and the returned tunnel handle when finished. + /// + /// + /// A tuple of (connection, tunnel). tunnel is + /// and MUST be disposed after the connection is closed. + /// + Task<(MySqlConnection Connection, IDisposable Tunnel)> OpenMySqlConnectionAsync( + string mysqlHost, int port, + string adminUser, string adminPassword); + + /// + /// Executes ALTER USER … IDENTIFIED BY … on a remote MySQL server via + /// . + /// + Task<(bool Success, string Error)> AlterMySqlUserPasswordAsync( + string mysqlHost, int port, + string adminUser, string adminPassword, + string targetUser, string newPassword); + + /// + /// Atomically swaps one secret reference on a running service: + /// removes and adds , + /// preserving the in-container path as (defaults to + /// when null, keeping the same /run/secrets/ filename). + /// + Task ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null); } public class StackInfo diff --git a/OTSSignsOrchestrator.Core/Services/InstanceService.cs b/OTSSignsOrchestrator.Core/Services/InstanceService.cs index 5ebf62e..8870d98 100644 --- a/OTSSignsOrchestrator.Core/Services/InstanceService.cs +++ b/OTSSignsOrchestrator.Core/Services/InstanceService.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Security.Cryptography; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -9,16 +8,18 @@ using OTSSignsOrchestrator.Core.Configuration; using OTSSignsOrchestrator.Core.Data; using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.Entities; +using static OTSSignsOrchestrator.Core.Configuration.AppConstants; namespace OTSSignsOrchestrator.Core.Services; /// -/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect). -/// New‐instance flow: +/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete). +/// All instance data is live — nothing is persisted locally beyond the audit log. +/// New-instance flow: /// 1. Clone template repo to local cache /// 2. Generate MySQL password → create Docker Swarm secret (never persisted locally) /// 3. Create MySQL database + user on external MySQL server via SSH -/// 4. Render combined compose YAML (no MySQL container, CIFS volumes, Newt service) +/// 4. Render combined compose YAML (no MySQL container, NFS volumes, Newt service) /// 5. Deploy stack via SSH /// public class InstanceService @@ -58,6 +59,18 @@ public class InstanceService _logger = logger; } + // ───────────────────────────────────────────────────────────────────────── + // Helper: derive the 3-letter abbreviation from a stack name like "acm-cms-stack" + // ───────────────────────────────────────────────────────────────────────── + private static string ExtractAbbrev(string stackName) + => stackName.EndsWith("-cms-stack") + ? stackName[..^"-cms-stack".Length] + : stackName.Split('-')[0]; + + // ───────────────────────────────────────────────────────────────────────── + // Create + // ───────────────────────────────────────────────────────────────────────── + /// /// Creates a new CMS instance: /// 1. Clone repo 2. Generate secrets 3. Create MySQL DB/user 4. Render compose 5. Deploy @@ -65,32 +78,14 @@ public class InstanceService public async Task CreateInstanceAsync(CreateInstanceDto dto, string? userId = null) { var sw = Stopwatch.StartNew(); - var opLog = StartOperation(OperationType.Create, userId); var abbrev = dto.CustomerAbbrev.Trim().ToLowerInvariant(); var stackName = $"{abbrev}-cms-stack"; + var opLog = StartOperation(OperationType.Create, userId, stackName); try { _logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName); - // ── 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) - { - _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 / refresh template repo ──────────────────────────── var repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl); var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat); @@ -101,90 +96,117 @@ public class InstanceService _logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl); var templateConfig = await _git.FetchAsync(repoUrl, repoPat); - // ── 2. Generate MySQL password → Docker Swarm secret ──────────── + // ── 1b. Remove any stale stack that might hold references to old secrets ─ + _logger.LogInformation("Removing stale stack (if any): {StackName}", stackName); + await _docker.RemoveStackAsync(stackName); + await Task.Delay(2000); + + // ── 2. Generate MySQL credentials → Docker Swarm secrets ──────── var mysqlPassword = GenerateRandomPassword(32); - var mysqlSecretName = $"{abbrev}-cms-db-password"; - await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword); - await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName); - _logger.LogInformation("Docker secret created: {SecretName}", mysqlSecretName); + var mySqlUserName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev); + + var pwdSecretName = CustomerMysqlPasswordSecretName(abbrev); + var userSecretName = CustomerMysqlUserSecretName(abbrev); + + var (pwdOk, _) = await _secrets.EnsureSecretAsync(pwdSecretName, mysqlPassword, rotate: true); + if (!pwdOk) + throw new InvalidOperationException($"Failed to create/rotate Docker secret '{pwdSecretName}'. Is a stale stack still referencing it?"); + _logger.LogInformation("Docker secret created/rotated: {SecretName}", pwdSecretName); + + var (userOk, _) = await _secrets.EnsureSecretAsync(userSecretName, mySqlUserName, rotate: true); + if (!userOk) + throw new InvalidOperationException($"Failed to create/rotate Docker secret '{userSecretName}'."); + _logger.LogInformation("Docker secret created/rotated: {SecretName}", userSecretName); + + // Global secrets: MySQL host + port (idempotent, shared across instances) + var mySqlHostValue = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); + var mySqlPortValue = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); + await _secrets.EnsureSecretAsync(GlobalMysqlHostSecretName, mySqlHostValue); + await _secrets.EnsureSecretAsync(GlobalMysqlPortSecretName, mySqlPortValue); + _logger.LogInformation("Global MySQL secrets ensured: {Host}, {Port}", GlobalMysqlHostSecretName, GlobalMysqlPortSecretName); + + // ── 2b. Create MySQL database + user ───────────────────────────── + _logger.LogInformation("Creating MySQL database and user for instance {Abbrev}", abbrev); + var (mysqlOk, mysqlMsg) = await CreateMySqlDatabaseAsync(abbrev, mysqlPassword); + if (!mysqlOk) + throw new InvalidOperationException($"MySQL database/user setup failed: {mysqlMsg}"); + _logger.LogInformation("MySQL setup complete: {Message}", mysqlMsg); + + mysqlPassword = string.Empty; // ── 3. Read settings ──────────────────────────────────────────── - var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); - var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); - var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); - var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev); + var mySqlHost = mySqlHostValue; + var mySqlPort = mySqlPortValue; + var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); + var mySqlUser = mySqlUserName; var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev); - var themePath = (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev); + var themePath = (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev); - var smtpServer = await _settings.GetAsync(SettingsService.SmtpServer, string.Empty); - var smtpUsername = await _settings.GetAsync(SettingsService.SmtpUsername, string.Empty); - var smtpPassword = await _settings.GetAsync(SettingsService.SmtpPassword, string.Empty); - var smtpUseTls = await _settings.GetAsync(SettingsService.SmtpUseTls, "YES"); - var smtpUseStartTls = await _settings.GetAsync(SettingsService.SmtpUseStartTls, "YES"); - var smtpRewriteDomain = await _settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty); - var smtpHostname = await _settings.GetAsync(SettingsService.SmtpHostname, string.Empty); + var smtpServer = await _settings.GetAsync(SettingsService.SmtpServer, string.Empty); + var smtpUsername = await _settings.GetAsync(SettingsService.SmtpUsername, string.Empty); + var smtpPassword = await _settings.GetAsync(SettingsService.SmtpPassword, string.Empty); + var smtpUseTls = await _settings.GetAsync(SettingsService.SmtpUseTls, "YES"); + var smtpUseStartTls = await _settings.GetAsync(SettingsService.SmtpUseStartTls, "YES"); + var smtpRewriteDomain = await _settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty); + var smtpHostname = await _settings.GetAsync(SettingsService.SmtpHostname, string.Empty); var smtpFromLineOverride = await _settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO"); var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"); - var cifsServer = dto.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer); - 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"); + var nfsServer = dto.NfsServer ?? await _settings.GetAsync(SettingsService.NfsServer); + var nfsExport = dto.NfsExport ?? await _settings.GetAsync(SettingsService.NfsExport); + var nfsExportFolder = dto.NfsExportFolder ?? await _settings.GetAsync(SettingsService.NfsExportFolder); + var nfsOptions = dto.NfsExtraOptions ?? await _settings.GetAsync(SettingsService.NfsOptions, string.Empty); - var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"); - var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"); - var memcachedImage = await _settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"); + var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"); + var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"); + var memcachedImage = await _settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"); var quickChartImage = await _settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart"); - var phpPostMaxSize = await _settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"); + var phpPostMaxSize = await _settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"); var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"); - var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); + var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); // ── 4. Render compose YAML from template ──────────────────────── var renderCtx = new RenderContext { - CustomerName = dto.CustomerName, - CustomerAbbrev = abbrev, - StackName = stackName, - CmsServerName = cmsServerName, - HostHttpPort = 80, - CmsImage = cmsImage, - MemcachedImage = memcachedImage, - QuickChartImage = quickChartImage, - NewtImage = newtImage, - ThemeHostPath = themePath, - MySqlHost = mySqlHost, - MySqlPort = mySqlPort, - MySqlDatabase = mySqlDbName, - MySqlUser = mySqlUser, - SmtpServer = smtpServer, - SmtpUsername = smtpUsername, - SmtpPassword = smtpPassword, - SmtpUseTls = smtpUseTls, - SmtpUseStartTls = smtpUseStartTls, - SmtpRewriteDomain = smtpRewriteDomain, - SmtpHostname = smtpHostname, + CustomerName = dto.CustomerName, + CustomerAbbrev = abbrev, + StackName = stackName, + CmsServerName = cmsServerName, + HostHttpPort = 80, + CmsImage = cmsImage, + MemcachedImage = memcachedImage, + QuickChartImage = quickChartImage, + NewtImage = newtImage, + ThemeHostPath = themePath, + MySqlHost = mySqlHost, + MySqlPort = mySqlPort, + MySqlDatabase = mySqlDbName, + MySqlUser = mySqlUser, + SmtpServer = smtpServer, + SmtpUsername = smtpUsername, + SmtpPassword = smtpPassword, + SmtpUseTls = smtpUseTls, + SmtpUseStartTls = smtpUseStartTls, + SmtpRewriteDomain = smtpRewriteDomain, + SmtpHostname = smtpHostname, SmtpFromLineOverride = smtpFromLineOverride, - PhpPostMaxSize = phpPostMaxSize, + PhpPostMaxSize = phpPostMaxSize, PhpUploadMaxFilesize = phpUploadMaxFilesize, - PhpMaxExecutionTime = phpMaxExecutionTime, - PangolinEndpoint = pangolinEndpoint, - NewtId = dto.NewtId, - NewtSecret = dto.NewtSecret, - CifsServer = cifsServer, - CifsShareName = cifsShareName, - CifsShareFolder = cifsShareFolder, - CifsUsername = cifsUsername, - CifsPassword = cifsPassword, - CifsExtraOptions = cifsOptions, + PhpMaxExecutionTime = phpMaxExecutionTime, + PangolinEndpoint = pangolinEndpoint, + NewtId = dto.NewtId, + NewtSecret = dto.NewtSecret, + NfsServer = nfsServer, + NfsExport = nfsExport, + NfsExportFolder = nfsExportFolder, + NfsExtraOptions = nfsOptions, }; - _logger.LogInformation("CIFS render values: server={CifsServer}, share={CifsShareName}, folder={CifsShareFolder}, user={CifsUsername}", - cifsServer, cifsShareName, cifsShareFolder, cifsUsername); + _logger.LogInformation("NFS render values: server={NfsServer}, export={NfsExport}, folder={NfsExportFolder}", + nfsServer, nfsExport, nfsExportFolder); var composeYaml = _compose.Render(templateConfig.Yaml, renderCtx); @@ -199,23 +221,22 @@ public class InstanceService if (!string.IsNullOrWhiteSpace(themePath)) await _docker.EnsureDirectoryAsync(themePath); - // ── 5b. Ensure SMB share folders exist ─────────────────────────── - if (!string.IsNullOrWhiteSpace(cifsServer) && !string.IsNullOrWhiteSpace(cifsShareName)) + // ── 5b. Ensure NFS export folders exist ───────────────────────── + if (!string.IsNullOrWhiteSpace(nfsServer) && !string.IsNullOrWhiteSpace(nfsExport)) { - 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); + var nfsFolders = ComposeRenderService.ExtractNfsDeviceFolders(composeYaml, nfsExport, nfsExportFolder); + _logger.LogInformation("Ensuring NFS export folders exist on {Server}:{Export} — {Folders}", + nfsServer, nfsExport, string.Join(", ", nfsFolders)); + var (nfsFoldersOk, nfsError) = await _docker.EnsureNfsFoldersWithErrorAsync(nfsServer, nfsExport, nfsFolders, nfsExportFolder); + if (!nfsFoldersOk) + throw new InvalidOperationException( + $"Failed to create NFS volume directories on {nfsServer}:{nfsExport}: {nfsError}\n" + + "Common causes: (1) SSH user needs passwordless sudo (NOPASSWD) for mount/umount/mkdir, " + + "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server."); } - // ── 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); + // ── 6. Remove stale NFS volumes ───────────────────────────────── + _logger.LogInformation("Removing stale NFS volumes for stack {StackName}", stackName); await _docker.RemoveStackVolumesAsync(stackName); // ── 7. Deploy stack ───────────────────────────────────────────── @@ -223,51 +244,24 @@ public class InstanceService if (!deployResult.Success) throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}"); - // ── 8. Record instance ────────────────────────────────────────── - var instance = new CmsInstance - { - CustomerName = dto.CustomerName, - CustomerAbbrev = abbrev, - StackName = stackName, - CmsServerName = cmsServerName, - HostHttpPort = 80, - ThemeHostPath = themePath, - LibraryHostPath = $"{abbrev}-cms-library", - SmtpServer = smtpServer, - SmtpUsername = smtpUsername, - TemplateRepoUrl = repoUrl ?? string.Empty, - TemplateRepoPat = repoPat, - Status = InstanceStatus.Active, - SshHostId = dto.SshHostId, - CifsServer = cifsServer, - CifsShareName = cifsShareName, - CifsShareFolder = cifsShareFolder, - CifsUsername = cifsUsername, - CifsPassword = cifsPassword, - CifsExtraOptions = cifsOptions, - }; - - _db.CmsInstances.Add(instance); sw.Stop(); - opLog.InstanceId = instance.Id; - opLog.Status = OperationStatus.Success; - opLog.Message = $"Instance deployed: {stackName}"; - opLog.DurationMs = sw.ElapsedMilliseconds; + 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); + _logger.LogInformation("Instance created: {StackName} | duration={DurationMs}ms", stackName, sw.ElapsedMilliseconds); deployResult.ServiceCount = 4; - deployResult.Message = "Instance deployed successfully."; + deployResult.Message = "Instance deployed successfully."; return deployResult; } catch (Exception ex) { sw.Stop(); - opLog.Status = OperationStatus.Failure; - opLog.Message = $"Create failed: {ex.Message}"; + opLog.Status = OperationStatus.Failure; + opLog.Message = $"Create failed: {ex.Message}"; opLog.DurationMs = sw.ElapsedMilliseconds; _db.OperationLogs.Add(opLog); await _db.SaveChangesAsync(); @@ -276,47 +270,351 @@ public class InstanceService } } + // ───────────────────────────────────────────────────────────────────────── + // Update / redeploy + // ───────────────────────────────────────────────────────────────────────── + /// - /// 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. + /// Redeploys an existing stack. All render parameters come from + /// (populated from live service inspection or user input), falling back to global settings + /// where values are null. + /// + public async Task UpdateInstanceAsync(string stackName, UpdateInstanceDto dto, string? userId = null) + { + var sw = Stopwatch.StartNew(); + var opLog = StartOperation(OperationType.Update, userId, stackName); + + try + { + var abbrev = dto.CustomerAbbrev?.Trim().ToLowerInvariant() ?? ExtractAbbrev(stackName); + var customerName = dto.CustomerName ?? abbrev; + + _logger.LogInformation("Updating instance: {StackName} (abbrev={Abbrev})", stackName, abbrev); + + var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); + var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); + var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); + var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev); + + // Ensure MySQL Docker secrets exist (idempotent) + var userSecretName = CustomerMysqlUserSecretName(abbrev); + await _secrets.EnsureSecretAsync(userSecretName, mySqlUser); + await _secrets.EnsureSecretAsync(GlobalMysqlHostSecretName, mySqlHost); + await _secrets.EnsureSecretAsync(GlobalMysqlPortSecretName, mySqlPort); + + var smtpServer = dto.SmtpServer ?? await _settings.GetAsync(SettingsService.SmtpServer, string.Empty); + var smtpUsername = dto.SmtpUsername ?? await _settings.GetAsync(SettingsService.SmtpUsername, string.Empty); + var smtpPassword = await _settings.GetAsync(SettingsService.SmtpPassword, string.Empty); + var smtpUseTls = await _settings.GetAsync(SettingsService.SmtpUseTls, "YES"); + var smtpUseStartTls = await _settings.GetAsync(SettingsService.SmtpUseStartTls, "YES"); + var smtpRewriteDomain = await _settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty); + var smtpHostname = await _settings.GetAsync(SettingsService.SmtpHostname, string.Empty); + var smtpFromLineOverride = await _settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO"); + + var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"); + + var cmsServerName = dto.CmsServerName + ?? (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev); + var hostHttpPort = dto.HostHttpPort ?? 80; + var themePath = dto.ThemeHostPath + ?? (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev); + + var nfsServer = dto.NfsServer ?? await _settings.GetAsync(SettingsService.NfsServer); + var nfsExport = dto.NfsExport ?? await _settings.GetAsync(SettingsService.NfsExport); + var nfsExportFolder = dto.NfsExportFolder ?? await _settings.GetAsync(SettingsService.NfsExportFolder); + var nfsOptions = dto.NfsExtraOptions ?? await _settings.GetAsync(SettingsService.NfsOptions, string.Empty); + + var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"); + var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"); + var memcachedImage = await _settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"); + var quickChartImage = await _settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart"); + + var phpPostMaxSize = await _settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"); + var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"); + var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); + + var repoUrl = dto.TemplateRepoUrl ?? await _settings.GetAsync(SettingsService.GitRepoUrl); + var repoPat = dto.TemplateRepoPat ?? await _settings.GetAsync(SettingsService.GitRepoPat); + + 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 = customerName, + CustomerAbbrev = abbrev, + StackName = stackName, + CmsServerName = cmsServerName, + HostHttpPort = hostHttpPort, + CmsImage = cmsImage, + MemcachedImage = memcachedImage, + QuickChartImage = quickChartImage, + NewtImage = newtImage, + ThemeHostPath = themePath, + MySqlHost = mySqlHost, + MySqlPort = mySqlPort, + MySqlDatabase = mySqlDbName, + MySqlUser = mySqlUser, + SmtpServer = smtpServer, + SmtpUsername = smtpUsername, + SmtpPassword = smtpPassword, + SmtpUseTls = smtpUseTls, + SmtpUseStartTls = smtpUseStartTls, + SmtpRewriteDomain = smtpRewriteDomain, + SmtpHostname = smtpHostname, + SmtpFromLineOverride = smtpFromLineOverride, + PhpPostMaxSize = phpPostMaxSize, + PhpUploadMaxFilesize = phpUploadMaxFilesize, + PhpMaxExecutionTime = phpMaxExecutionTime, + PangolinEndpoint = pangolinEndpoint, + NewtId = dto.NewtId, + NewtSecret = dto.NewtSecret, + NfsServer = nfsServer, + NfsExport = nfsExport, + NfsExportFolder = nfsExportFolder, + NfsExtraOptions = nfsOptions, + }; + + _logger.LogInformation("NFS render values (update): server={NfsServer}, export={NfsExport}, folder={NfsExportFolder}", + nfsServer, nfsExport, nfsExportFolder); + + var composeYaml = _compose.Render(templateConfig.Yaml, renderCtx); + + if (_dockerOptions.ValidateBeforeDeploy) + { + var validationResult = _validation.Validate(composeYaml, abbrev); + if (!validationResult.IsValid) + throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}"); + } + + if (!string.IsNullOrWhiteSpace(themePath)) + await _docker.EnsureDirectoryAsync(themePath); + + if (!string.IsNullOrWhiteSpace(nfsServer) && !string.IsNullOrWhiteSpace(nfsExport)) + { + var nfsFolders = ComposeRenderService.ExtractNfsDeviceFolders(composeYaml, nfsExport, nfsExportFolder); + _logger.LogInformation("Ensuring NFS export folders on {Server}:{Export} — {Folders}", + nfsServer, nfsExport, string.Join(", ", nfsFolders)); + var (nfsFoldersOk, nfsError) = await _docker.EnsureNfsFoldersWithErrorAsync(nfsServer, nfsExport, nfsFolders, nfsExportFolder); + if (!nfsFoldersOk) + throw new InvalidOperationException( + $"Failed to create NFS volume directories on {nfsServer}:{nfsExport}: {nfsError}\n" + + "Common causes: (1) SSH user needs passwordless sudo (NOPASSWD) for mount/umount/mkdir, " + + "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server."); + } + + _logger.LogInformation("Removing stale NFS volumes for stack {StackName}", stackName); + await _docker.RemoveStackVolumesAsync(stackName); + + var deployResult = await _docker.DeployStackAsync(stackName, composeYaml, resolveImage: true); + if (!deployResult.Success) + throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}"); + + sw.Stop(); + opLog.Status = OperationStatus.Success; + opLog.Message = $"Instance updated: {stackName}"; + opLog.DurationMs = sw.ElapsedMilliseconds; + _db.OperationLogs.Add(opLog); + await _db.SaveChangesAsync(); + + 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: {StackName}", stackName); + throw; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Delete + // ───────────────────────────────────────────────────────────────────────── + + public async Task DeleteInstanceAsync( + string stackName, + string customerAbbrev, + bool retainSecrets = false, + bool clearXiboCreds = true, + string? userId = null) + { + var sw = Stopwatch.StartNew(); + var opLog = StartOperation(OperationType.Delete, userId, stackName); + + try + { + var abbrev = customerAbbrev.Trim().ToLowerInvariant(); + + _logger.LogInformation("Deleting instance: {StackName} (abbrev={Abbrev}) retainSecrets={RetainSecrets}", + stackName, abbrev, retainSecrets); + + var result = await _docker.RemoveStackAsync(stackName); + + if (!retainSecrets) + { + _logger.LogInformation("Dropping MySQL database and user for instance {StackName}", stackName); + var (dropOk, dropMsg) = await DropMySqlDatabaseAsync(abbrev); + if (!dropOk) + _logger.LogWarning("MySQL cleanup incomplete for {StackName}: {Message}", stackName, dropMsg); + else + _logger.LogInformation("MySQL cleanup complete: {Message}", dropMsg); + + foreach (var secretName in AllCustomerMysqlSecretNames(abbrev)) + await _secrets.DeleteSecretAsync(secretName); + } + + sw.Stop(); + opLog.Status = OperationStatus.Success; + opLog.Message = $"Instance deleted: {stackName}"; + opLog.DurationMs = sw.ElapsedMilliseconds; + _db.OperationLogs.Add(opLog); + await _db.SaveChangesAsync(); + + 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: {StackName}", stackName); + throw; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // MySQL password rotation + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Rotates the MySQL password for an instance: generates a new password, updates the + /// Docker Swarm secret, updates the MySQL user via ALTER USER, then redeploys the stack. + /// + public async Task<(bool Success, string Message)> RotateMySqlPasswordAsync(string stackName, string? userId = null) + { + var abbrev = ExtractAbbrev(stackName); + var secretName = CustomerMysqlPasswordSecretName(abbrev); + var tempSecret = $"{secretName}-rot"; + var webService = $"{stackName}_{abbrev}-web"; + var newPassword = GenerateRandomPassword(32); + + _logger.LogInformation("Rotating MySQL password for instance {StackName}", stackName); + + // ── Step 1: Create temp secret (new value, different name) ──────────── + // We cannot delete the live secret while the service is using it, so we + // stage the new value under a temporary name first. + var (tempCreated, _) = await _secrets.EnsureSecretAsync(tempSecret, newPassword, rotate: false); + if (!tempCreated) + return (false, $"Failed to create temporary rotation secret '{tempSecret}'."); + + // ── Step 2: Update MySQL password via SSH ───────────────────────────── + // Connect through the Docker SSH host so the desktop app doesn't need + // direct TCP access to the MySQL server. + var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); + var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); + var mySqlAdminUser = await _settings.GetAsync(SettingsService.MySqlAdminUser, "root"); + var mySqlAdminPassword = await _settings.GetAsync(SettingsService.MySqlAdminPassword, string.Empty); + var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev); + + if (!int.TryParse(mySqlPort, out var port)) port = 3306; + + var (mysqlOk, mysqlErr) = await _docker.AlterMySqlUserPasswordAsync( + mySqlHost, port, mySqlAdminUser, mySqlAdminPassword, mySqlUser, newPassword); + + if (!mysqlOk) + { + // No service swap has happened yet — clean up temp and abort cleanly. + await _secrets.DeleteSecretAsync(tempSecret); + return (false, $"MySQL update failed (no service disruption): {mysqlErr}"); + } + + _logger.LogInformation("MySQL password updated for user {User}", mySqlUser); + + // ── Step 3: Swap service from old secret → temp (same /run/secrets/ path) + // Triggers rolling restart; containers pick up the new password. + if (!await _docker.ServiceSwapSecretAsync(webService, secretName, tempSecret, targetAlias: secretName)) + { + // Rollback MySQL — try to restore old password. We don't know it, so + // log a warning; the temp secret can be cleaned up manually. + _logger.LogError( + "Service swap failed. MySQL has new password but service still uses old secret. " + + "Manual intervention may be required for {Service}", webService); + return (false, $"Service swap failed for '{webService}'. MySQL password was updated but secret swap did not complete."); + } + + _logger.LogInformation("Service {Service} rolling restart with temp secret {TempSecret}", webService, tempSecret); + + // ── Step 4: Delete the old secret (now unreferenced by any service) ─── + await _secrets.DeleteSecretAsync(secretName); + + // ── Step 5: Recreate permanent secret under the original name ───────── + var (secretCreated, secretId) = await _secrets.EnsureSecretAsync(secretName, newPassword, rotate: false); + if (!secretCreated) + { + _logger.LogError("Failed to recreate permanent secret {SecretName}; temp secret remains active", secretName); + return (false, $"Rotation partially complete: temp secret '{tempSecret}' is active but '{secretName}' could not be recreated."); + } + + // ── Step 6: Swap service back to permanent name; second rolling restart ─ + if (!await _docker.ServiceSwapSecretAsync(webService, tempSecret, secretName)) + { + _logger.LogWarning( + "Final swap to permanent secret failed for {Service}; temp secret '{TempSecret}' is still active. " + + "Service is functional but secret name normalisation is pending.", webService, tempSecret); + return (true, $"MySQL password rotated for '{stackName}' (temp secret active; manual final swap may be needed)."); + } + + await _secrets.DeleteSecretAsync(tempSecret); + + _logger.LogInformation("Docker secret fully rotated: {SecretName} (new id={SecretId})", secretName, secretId); + return (true, $"MySQL password rotated successfully for '{stackName}'."); + } + + // ───────────────────────────────────────────────────────────────────────── + // MySQL database helpers + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Creates MySQL database and user on the external MySQL server via SSH tunnel. /// public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync( string abbrev, string mysqlPassword) { - var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); - var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); - var mySqlAdminUser = await _settings.GetAsync(SettingsService.MySqlAdminUser, "root"); + var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); + var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); + var mySqlAdminUser = await _settings.GetAsync(SettingsService.MySqlAdminUser, "root"); var mySqlAdminPassword = await _settings.GetAsync(SettingsService.MySqlAdminPassword, string.Empty); - 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 dbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); + var userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev); - _logger.LogInformation("Creating MySQL database {Db} and user {User} via direct TCP", dbName, userName); + _logger.LogInformation("Creating MySQL database {Db} and user {User} via SSH tunnel", dbName, userName); if (!int.TryParse(mySqlPort, out var port)) port = 3306; - var csb = new MySqlConnectionStringBuilder - { - 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(); + var (connection, tunnel) = await _docker.OpenMySqlConnectionAsync( + mySqlHost, port, mySqlAdminUser, mySqlAdminPassword); + await using var _ = connection; + using var __ = tunnel; - // 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 escapedDb = dbName.Replace("`", "``"); var escapedUser = userName.Replace("'", "''"); await using (var cmd = connection.CreateCommand()) @@ -332,6 +630,13 @@ public class InstanceService await cmd.ExecuteNonQueryAsync(); } + await using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = $"ALTER USER '{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}'@'%'"; @@ -344,7 +649,33 @@ public class InstanceService await cmd.ExecuteNonQueryAsync(); } - _logger.LogInformation("MySQL database {Db} and user {User} created successfully", dbName, userName); + // Verify the new user's credentials by opening a connection as that user + try + { + var parsed = new MySqlConnectionStringBuilder(connection.ConnectionString); + var localPort = (int)parsed.Port; + + var testCsb = new MySqlConnectionStringBuilder + { + Server = "127.0.0.1", + Port = (uint)localPort, + UserID = userName, + Password = mysqlPassword, + ConnectionTimeout = 5, + SslMode = MySqlSslMode.Disabled, + }; + + await using var testConn = new MySqlConnection(testCsb.ConnectionString); + await testConn.OpenAsync(); + await testConn.CloseAsync(); + } + catch (MySqlException ex) + { + _logger.LogError(ex, "Verification login failed for new MySQL user {User}", userName); + return (false, $"Verification login failed for user '{userName}': {ex.Message}"); + } + + _logger.LogInformation("MySQL database {Db} and user {User} created and verified successfully", dbName, userName); return (true, $"Database '{dbName}' and user '{userName}' created."); } catch (MySqlException ex) @@ -354,296 +685,78 @@ public class InstanceService } } - public async Task UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null) + /// + /// Drops the MySQL database and user for a given instance abbreviation. + /// + public async Task<(bool Success, string Message)> DropMySqlDatabaseAsync(string abbrev) { - var sw = Stopwatch.StartNew(); - var opLog = StartOperation(OperationType.Update, userId); + var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); + var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); + var mySqlAdminUser = await _settings.GetAsync(SettingsService.MySqlAdminUser, "root"); + var mySqlAdminPassword = await _settings.GetAsync(SettingsService.MySqlAdminPassword, string.Empty); + + var dbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); + var userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev); + + _logger.LogInformation("Dropping MySQL database {Db} and user {User} via SSH tunnel", dbName, userName); + + if (!int.TryParse(mySqlPort, out var port)) + port = 3306; try { - var instance = await _db.CmsInstances.FindAsync(id) - ?? throw new KeyNotFoundException($"Instance {id} not found."); + var (connection, tunnel) = await _docker.OpenMySqlConnectionAsync( + mySqlHost, port, mySqlAdminUser, mySqlAdminPassword); + await using var _ = connection; + using var __ = tunnel; - _logger.LogInformation("Updating instance: {StackName} (id={Id})", instance.StackName, id); + var escapedDb = dbName.Replace("`", "``"); + var escapedUser = userName.Replace("'", "''"); - 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; - if (dto.CifsServer != null) instance.CifsServer = dto.CifsServer; - 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; - - var abbrev = instance.CustomerAbbrev; - var mysqlSecretName = $"{abbrev}-cms-db-password"; - - // Read current settings for re-render - var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost"); - var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306"); - var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); - var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev); - - var smtpServer = instance.SmtpServer; - var smtpUsername = instance.SmtpUsername; - var smtpPassword = await _settings.GetAsync(SettingsService.SmtpPassword, string.Empty); - var smtpUseTls = await _settings.GetAsync(SettingsService.SmtpUseTls, "YES"); - var smtpUseStartTls = await _settings.GetAsync(SettingsService.SmtpUseStartTls, "YES"); - var smtpRewriteDomain = await _settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty); - var smtpHostname = await _settings.GetAsync(SettingsService.SmtpHostname, string.Empty); - var smtpFromLineOverride = await _settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO"); - - var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"); - - // 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"); - var memcachedImage = await _settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"); - var quickChartImage = await _settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart"); - - var phpPostMaxSize = await _settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"); - 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 + await using (var cmd = connection.CreateCommand()) { - CustomerName = instance.CustomerName, - CustomerAbbrev = abbrev, - StackName = instance.StackName, - CmsServerName = instance.CmsServerName, - HostHttpPort = instance.HostHttpPort, - CmsImage = cmsImage, - MemcachedImage = memcachedImage, - QuickChartImage = quickChartImage, - NewtImage = newtImage, - ThemeHostPath = instance.ThemeHostPath, - MySqlHost = mySqlHost, - MySqlPort = mySqlPort, - MySqlDatabase = mySqlDbName, - MySqlUser = mySqlUser, - SmtpServer = smtpServer, - SmtpUsername = smtpUsername, - SmtpPassword = smtpPassword, - SmtpUseTls = smtpUseTls, - SmtpUseStartTls = smtpUseStartTls, - SmtpRewriteDomain = smtpRewriteDomain, - SmtpHostname = smtpHostname, - SmtpFromLineOverride = smtpFromLineOverride, - PhpPostMaxSize = phpPostMaxSize, - PhpUploadMaxFilesize = phpUploadMaxFilesize, - PhpMaxExecutionTime = phpMaxExecutionTime, - PangolinEndpoint = pangolinEndpoint, - CifsServer = cifsServer, - CifsShareName = cifsShareName, - CifsShareFolder = cifsShareFolder, - CifsUsername = cifsUsername, - CifsPassword = cifsPassword, - CifsExtraOptions = cifsOptions, - }; - - _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) - { - var validationResult = _validation.Validate(composeYaml, abbrev); - if (!validationResult.IsValid) - throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}"); + cmd.CommandText = $"DROP DATABASE IF EXISTS `{escapedDb}`"; + await cmd.ExecuteNonQueryAsync(); } - // 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)) + await using (var cmd = connection.CreateCommand()) { - 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); + cmd.CommandText = $"DROP USER IF EXISTS '{escapedUser}'@'%'"; + await cmd.ExecuteNonQueryAsync(); } - // 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}"); - - 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(); - - 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; - } - } - - public async Task DeleteInstanceAsync( - Guid id, bool retainSecrets = false, bool clearXiboCreds = true, string? userId = null) - { - var sw = Stopwatch.StartNew(); - var opLog = StartOperation(OperationType.Delete, userId); - - 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); - - var result = await _docker.RemoveStackAsync(instance.StackName); - - if (!retainSecrets) + await using (var cmd = connection.CreateCommand()) { - var mysqlSecretName = $"{instance.CustomerAbbrev}-cms-db-password"; - await _secrets.DeleteSecretAsync(mysqlSecretName); - var secretMeta = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == mysqlSecretName); - if (secretMeta != null) - _db.SecretMetadata.Remove(secretMeta); + cmd.CommandText = "FLUSH PRIVILEGES"; + await cmd.ExecuteNonQueryAsync(); } - 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(); - - result.Message = "Instance deleted."; - return result; + _logger.LogInformation("MySQL database {Db} and user {User} dropped successfully", dbName, userName); + return (true, $"Database '{dbName}' and user '{userName}' dropped."); } - catch (Exception ex) + catch (MySqlException 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; + _logger.LogError(ex, "MySQL drop failed for database {Db}", dbName); + return (false, $"MySQL drop failed: {ex.Message}"); } } - public async Task GetInstanceAsync(Guid id) - => await _db.CmsInstances.Include(i => i.SshHost).FirstOrDefaultAsync(i => i.Id == id); + // ───────────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────────── - public async Task<(List Items, int TotalCount)> ListInstancesAsync( - int page = 1, int pageSize = 50, string? filter = null) - { - var query = _db.CmsInstances.Include(i => i.SshHost).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); - } - - 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) - => new OperationLog { Operation = type, UserId = userId, Status = OperationStatus.Pending }; + private static OperationLog StartOperation(OperationType type, string? userId, string? stackName = null) + => new OperationLog { Operation = type, UserId = userId, Status = OperationStatus.Pending, StackName = stackName }; private static string GenerateRandomPassword(int length) { - const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"; + // Only alphanumeric chars — special characters (! @ # $ % ^ & *) can be + // mangled by shell escaping in the printf→docker-secret-create SSH pipeline. + const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; return RandomNumberGenerator.GetString(chars, length); } } + + +/// +/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect). diff --git a/OTSSignsOrchestrator.Core/Services/SettingsService.cs b/OTSSignsOrchestrator.Core/Services/SettingsService.cs index acd9806..c15248e 100644 --- a/OTSSignsOrchestrator.Core/Services/SettingsService.cs +++ b/OTSSignsOrchestrator.Core/Services/SettingsService.cs @@ -21,7 +21,7 @@ public class SettingsService public const string CatMySql = "MySql"; public const string CatSmtp = "Smtp"; public const string CatPangolin = "Pangolin"; - public const string CatCifs = "Cifs"; + public const string CatNfs = "Nfs"; public const string CatDefaults = "Defaults"; // ── Key constants ────────────────────────────────────────────────────── @@ -49,13 +49,11 @@ public class SettingsService // Pangolin public const string PangolinEndpoint = "Pangolin.Endpoint"; - // CIFS - public const string CifsServer = "Cifs.Server"; - 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"; + // NFS + public const string NfsServer = "Nfs.Server"; + public const string NfsExport = "Nfs.Export"; + public const string NfsExportFolder = "Nfs.ExportFolder"; + public const string NfsOptions = "Nfs.Options"; // Instance Defaults public const string DefaultCmsImage = "Defaults.CmsImage"; diff --git a/OTSSignsOrchestrator.Desktop/Models/LiveStackItem.cs b/OTSSignsOrchestrator.Desktop/Models/LiveStackItem.cs new file mode 100644 index 0000000..c8f4930 --- /dev/null +++ b/OTSSignsOrchestrator.Desktop/Models/LiveStackItem.cs @@ -0,0 +1,25 @@ +using OTSSignsOrchestrator.Core.Models.Entities; + +namespace OTSSignsOrchestrator.Desktop.Models; + +/// +/// Represents a CMS stack discovered live from a Docker Swarm host. +/// No data is persisted locally — all values come from docker stack ls / inspect. +/// +public class LiveStackItem +{ + /// Docker stack name, e.g. "acm-cms-stack". + public string StackName { get; set; } = string.Empty; + + /// 3-letter abbreviation derived from the stack name. + public string CustomerAbbrev { get; set; } = string.Empty; + + /// Number of services reported by docker stack ls. + public int ServiceCount { get; set; } + + /// The SSH host this stack was found on. + public SshHost Host { get; set; } = null!; + + /// Label of the host — convenience property for data-binding. + public string HostLabel => Host?.Label ?? string.Empty; +} diff --git a/OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs b/OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs index 1d4a2f1..64a1288 100644 --- a/OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs +++ b/OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs @@ -93,6 +93,31 @@ public class SshConnectionService : IDisposable }); } + /// + /// Run a command on the remote host with a timeout. + /// Returns exit code -1 and an error message if the command times out. + /// + public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandAsync(SshHost host, string command, TimeSpan timeout) + { + return await Task.Run(() => + { + var client = GetClient(host); + using var cmd = client.CreateCommand(command); + cmd.CommandTimeout = timeout; + try + { + cmd.Execute(); + return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error); + } + catch (Renci.SshNet.Common.SshOperationTimeoutException) + { + _logger.LogWarning("SSH command timed out after {Timeout}s: {Command}", + timeout.TotalSeconds, command.Length > 120 ? command[..120] + "…" : command); + return (-1, string.Empty, $"Command timed out after {timeout.TotalSeconds}s"); + } + }); + } + /// /// Run a command that requires stdin input (e.g., docker stack deploy --compose-file -). /// @@ -171,6 +196,23 @@ public class SshConnectionService : IDisposable return new SshClient(connInfo); } + /// + /// Opens an SSH local port-forward from 127.0.0.1:<auto> → : + /// through the existing SSH connection for . + /// The caller must dispose the returned to close the tunnel. + /// + public ForwardedPortLocal OpenForwardedPort(SshHost host, string remoteHost, uint remotePort) + { + var client = GetClient(host); + // Port 0 lets the OS assign a free local port; SSH.NET updates BoundPort after Start(). + var tunnel = new ForwardedPortLocal("127.0.0.1", 0, remoteHost, remotePort); + client.AddForwardedPort(tunnel); + tunnel.Start(); + _logger.LogDebug("SSH tunnel opened: 127.0.0.1:{LocalPort} → {RemoteHost}:{RemotePort}", + tunnel.BoundPort, remoteHost, remotePort); + return tunnel; + } + public void Dispose() { lock (_lock) diff --git a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs index e3183f2..18a1b3b 100644 --- a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs +++ b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using MySqlConnector; using OTSSignsOrchestrator.Core.Configuration; using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.Entities; @@ -162,66 +163,282 @@ public class SshDockerCliService : IDockerCliService return exitCode == 0; } - public async Task EnsureSmbFoldersAsync( - string cifsServer, - string cifsShareName, - string cifsUsername, - string cifsPassword, + public async Task EnsureNfsFoldersAsync( + string nfsServer, + string nfsExport, IEnumerable folderNames, - string? cifsShareFolder = null) + string? nfsExportFolder = null) { EnsureHost(); - var allSucceeded = true; - var subFolder = (cifsShareFolder ?? string.Empty).Trim('/'); + var exportPath = (nfsExport ?? string.Empty).Trim('/'); + var subFolder = (nfsExportFolder ?? string.Empty).Trim('/'); - // If a subfolder is specified, ensure it exists first - if (!string.IsNullOrEmpty(subFolder)) + // Build the sub-path beneath the mount point where volume folders will be created + var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}"; + + // Build mkdir targets relative to the temporary mount point + var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList(); + var mkdirTargets = string.Join(" ", folderList); + + // Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup + // Use addr= to pin the server IP — avoids "Server address does not match proto= option" + // errors when the hostname resolves to IPv6 but proto=tcp implies IPv4. + var script = $""" + set -e + MNT=$(mktemp -d) + sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT" + sudo mkdir -p {mkdirTargets} + sudo umount "$MNT" + rmdir "$MNT" + """; + + _logger.LogInformation( + "Mounting NFS export {Server}:/{Export} on Docker host {Host} to create {Count} folders", + nfsServer, exportPath, _currentHost!.Label, folderList.Count); + + var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30)); + + if (exitCode == 0) { - 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; - } + _logger.LogInformation( + "NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)", + _currentHost.Label, nfsServer, exportPath, subPath, folderList.Count); + } + else + { + _logger.LogWarning( + "Failed to create NFS export folders on {Host}: {Error}", + _currentHost.Label, (stderr ?? stdout ?? "unknown error").Trim()); + return false; } - // Build the target path prefix for volume folders - var pathPrefix = string.IsNullOrEmpty(subFolder) ? string.Empty : $"{subFolder}/"; + return true; + } - foreach (var folder in folderNames) + public async Task<(bool Success, string? Error)> EnsureNfsFoldersWithErrorAsync( + string nfsServer, + string nfsExport, + IEnumerable folderNames, + string? nfsExportFolder = null) + { + EnsureHost(); + var exportPath = (nfsExport ?? string.Empty).Trim('/'); + var subFolder = (nfsExportFolder ?? string.Empty).Trim('/'); + var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}"; + var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList(); + var mkdirTargets = string.Join(" ", folderList); + + var script = $""" + set -e + MNT=$(mktemp -d) + sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT" + sudo mkdir -p {mkdirTargets} + sudo umount "$MNT" + rmdir "$MNT" + """; + + _logger.LogInformation( + "Mounting NFS export {Server}:/{Export} on Docker host {Host} to create {Count} folders", + nfsServer, exportPath, _currentHost!.Label, folderList.Count); + + var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30)); + + if (exitCode == 0) { - 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; - } + _logger.LogInformation( + "NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)", + _currentHost.Label, nfsServer, exportPath, subPath, folderList.Count); + return (true, null); } - return allSucceeded; + var error = (stderr ?? stdout ?? "unknown error").Trim(); + _logger.LogWarning( + "Failed to create NFS export folders on {Host}: {Error}", + _currentHost.Label, error); + return (false, error); + } + + public async Task ForceUpdateServiceAsync(string serviceName) + { + EnsureHost(); + _logger.LogInformation("Force-updating service {ServiceName} on {Host}", serviceName, _currentHost!.Label); + var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"docker service update --force {serviceName}"); + if (exitCode != 0) + _logger.LogWarning("Force-update failed for {ServiceName}: {Error}", serviceName, stderr); + return exitCode == 0; + } + + public async Task<(MySqlConnection Connection, IDisposable Tunnel)> OpenMySqlConnectionAsync( + string mysqlHost, int port, + string adminUser, string adminPassword) + { + EnsureHost(); + _logger.LogInformation( + "Opening tunnelled MySQL connection to {MysqlHost}:{Port} via SSH", + mysqlHost, port); + + var tunnel = _ssh.OpenForwardedPort(_currentHost!, mysqlHost, (uint)port); + var localPort = (int)tunnel.BoundPort; + + var csb = new MySqlConnectionStringBuilder + { + Server = "127.0.0.1", + Port = (uint)localPort, + UserID = adminUser, + Password = adminPassword, + ConnectionTimeout = 15, + SslMode = MySqlSslMode.Disabled, + }; + + var connection = new MySqlConnection(csb.ConnectionString); + try + { + await connection.OpenAsync(); + return (connection, tunnel); + } + catch + { + await connection.DisposeAsync(); + tunnel.Dispose(); + throw; + } + } + + public async Task<(bool Success, string Error)> AlterMySqlUserPasswordAsync( + string mysqlHost, int port, + string adminUser, string adminPassword, + string targetUser, string newPassword) + { + _logger.LogInformation( + "Altering MySQL password for user {User} on {MysqlHost}:{Port} via SSH tunnel", + targetUser, mysqlHost, port); + + try + { + var (connection, tunnel) = await OpenMySqlConnectionAsync(mysqlHost, port, adminUser, adminPassword); + await using (connection) + using (tunnel) + { + var escapedUser = targetUser.Replace("'", "''"); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"ALTER USER '{escapedUser}'@'%' IDENTIFIED BY @pwd"; + cmd.Parameters.AddWithValue("@pwd", newPassword); + await cmd.ExecuteNonQueryAsync(); + } + + _logger.LogInformation("MySQL password updated for user {User} via SSH tunnel", targetUser); + return (true, string.Empty); + } + catch (MySqlException ex) + { + _logger.LogError(ex, "MySQL ALTER USER failed via SSH tunnel for user {User}", targetUser); + return (false, ex.Message); + } + } + + public async Task ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null) + { + EnsureHost(); + var target = targetAlias ?? oldSecretName; + var cmd = $"docker service update --secret-rm {oldSecretName} --secret-add \"source={newSecretName},target={target}\" {serviceName}"; + _logger.LogInformation( + "Swapping secret on {ServiceName}: {OldSecret} → {NewSecret} (target={Target})", + serviceName, oldSecretName, newSecretName, target); + var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, cmd); + if (exitCode != 0) + _logger.LogError("Secret swap failed for {ServiceName}: {Error}", serviceName, stderr); + return exitCode == 0; + } + + public async Task> ListNodesAsync() + { + EnsureHost(); + + _logger.LogInformation("Listing swarm nodes via SSH on {Host}", _currentHost!.Label); + + // Use docker node inspect on all nodes to get IP addresses (Status.Addr) + // that are not available from 'docker node ls'. + // First, get all node IDs. + var (lsExit, lsOut, lsErr) = await _ssh.RunCommandAsync( + _currentHost!, "docker node ls --format '{{.ID}}'"); + + if (lsExit != 0) + { + var msg = (lsErr ?? lsOut ?? "unknown error").Trim(); + _logger.LogWarning("docker node ls failed on {Host} (exit {Code}): {Error}", + _currentHost.Label, lsExit, msg); + throw new InvalidOperationException( + $"Failed to list swarm nodes on {_currentHost.Label}: {msg}"); + } + + if (string.IsNullOrWhiteSpace(lsOut)) + return new List(); + + var nodeIds = lsOut.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(id => id.Trim()) + .Where(id => !string.IsNullOrEmpty(id)) + .ToList(); + + if (nodeIds.Count == 0) + return new List(); + + // Inspect all nodes in a single call to get full details including IP address + var ids = string.Join(" ", nodeIds); + var format = "'{{.ID}}\t{{.Description.Hostname}}\t{{.Status.State}}\t{{.Spec.Availability}}\t{{.ManagerStatus.Addr}}\t{{.Status.Addr}}\t{{.Description.Engine.EngineVersion}}\t{{.Spec.Role}}'"; + var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync( + _currentHost!, $"docker node inspect --format {format} {ids}"); + + if (exitCode != 0) + { + var msg = (stderr ?? stdout ?? "unknown error").Trim(); + _logger.LogWarning("docker node inspect failed on {Host} (exit {Code}): {Error}", + _currentHost.Label, exitCode, msg); + throw new InvalidOperationException( + $"Failed to inspect swarm nodes on {_currentHost.Label}: {msg}"); + } + + if (string.IsNullOrWhiteSpace(stdout)) + return new List(); + + return stdout + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => + { + var parts = line.Split('\t', 8); + // ManagerStatus.Addr includes port (e.g. "10.0.0.1:2377"); Status.Addr is just the IP. + // Prefer Status.Addr; fall back to ManagerStatus.Addr (strip port) if Status.Addr is empty/template-error. + var statusAddr = parts.Length > 5 ? parts[5].Trim() : ""; + var managerAddr = parts.Length > 4 ? parts[4].Trim() : ""; + var ip = statusAddr; + if (string.IsNullOrEmpty(ip) || ip.StartsWith("<") || ip.StartsWith("{")) + { + // managerAddr may be "10.0.0.1:2377" + ip = managerAddr.Contains(':') ? managerAddr[..managerAddr.LastIndexOf(':')] : managerAddr; + } + // Clean up template rendering artefacts like "" + if (ip.StartsWith("<") || ip.StartsWith("{")) + ip = ""; + + var role = parts.Length > 7 ? parts[7].Trim() : ""; + var managerStatus = ""; + if (string.Equals(role, "manager", StringComparison.OrdinalIgnoreCase)) + { + // Determine if this is the leader by checking if ManagerStatus.Addr is non-empty + managerStatus = !string.IsNullOrEmpty(managerAddr) && !managerAddr.StartsWith("<") ? "Reachable" : ""; + } + + return new NodeInfo + { + Id = parts.Length > 0 ? parts[0].Trim() : "", + Hostname = parts.Length > 1 ? parts[1].Trim() : "", + Status = parts.Length > 2 ? parts[2].Trim() : "", + Availability = parts.Length > 3 ? parts[3].Trim() : "", + ManagerStatus = managerStatus, + IpAddress = ip, + EngineVersion = parts.Length > 6 ? parts[6].Trim() : "" + }; + }) + .ToList(); } private void EnsureHost() diff --git a/OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs b/OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs index b406c9b..ee322f9 100644 --- a/OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs +++ b/OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs @@ -43,7 +43,12 @@ public class SshDockerSecretsService : IDockerSecretsService if (existing != null && rotate) { _logger.LogInformation("Rotating secret via SSH: {SecretName} (old id={SecretId})", name, existing.Value.id); - await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}"); + var (rmExit, _, rmErr) = await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}"); + if (rmExit != 0) + { + _logger.LogError("Failed to remove old secret for rotation: {SecretName} | error={Error}", name, rmErr); + return (false, string.Empty); + } } // Create secret via stdin diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs index f13a145..4d028e6 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/CreateInstanceViewModel.cs @@ -1,5 +1,7 @@ using System.Collections.ObjectModel; -using System.Security.Cryptography; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.EntityFrameworkCore; @@ -35,18 +37,23 @@ public partial class CreateInstanceViewModel : ObservableObject [ObservableProperty] private string _newtId = string.Empty; [ObservableProperty] private string _newtSecret = string.Empty; - // CIFS / SMB credentials (per-instance, defaults loaded from global settings) - [ObservableProperty] private string _cifsServer = 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; + // NFS volume settings (per-instance, defaults loaded from global settings) + [ObservableProperty] private string _nfsServer = string.Empty; + [ObservableProperty] private string _nfsExport = string.Empty; + [ObservableProperty] private string _nfsExportFolder = string.Empty; + [ObservableProperty] private string _nfsExtraOptions = string.Empty; // SSH host selection [ObservableProperty] private ObservableCollection _availableHosts = new(); [ObservableProperty] private SshHost? _selectedSshHost; + // YML preview + [ObservableProperty] private string _previewYml = string.Empty; + [ObservableProperty] private bool _isLoadingYml; + + public bool HasPreviewYml => !string.IsNullOrEmpty(PreviewYml); + partial void OnPreviewYmlChanged(string value) => OnPropertyChanged(nameof(HasPreviewYml)); + // ── Derived preview properties ─────────────────────────────────────────── public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—"; @@ -55,14 +62,17 @@ public partial class CreateInstanceViewModel : ObservableObject public string PreviewServiceChart => Valid ? $"{Abbrev}-quickchart" : "—"; public string PreviewServiceNewt => Valid ? $"{Abbrev}-newt" : "—"; public string PreviewNetwork => Valid ? $"{Abbrev}-net" : "—"; - public string PreviewVolCustom => Valid ? $"{Abbrev}-cms-custom" : "—"; - public string PreviewVolBackup => Valid ? $"{Abbrev}-cms-backup" : "—"; - public string PreviewVolLibrary => Valid ? $"{Abbrev}-cms-library" : "—"; - public string PreviewVolUserscripts => Valid ? $"{Abbrev}-cms-userscripts": "—"; - public string PreviewVolCaCerts => Valid ? $"{Abbrev}-cms-ca-certs" : "—"; + public string PreviewVolCustom => Valid ? $"{Abbrev}/cms-custom" : "—"; + public string PreviewVolBackup => Valid ? $"{Abbrev}/cms-backup" : "—"; + public string PreviewVolLibrary => Valid ? $"{Abbrev}/cms-library" : "—"; + public string PreviewVolUserscripts => Valid ? $"{Abbrev}/cms-userscripts": "—"; + public string PreviewVolCaCerts => Valid ? $"{Abbrev}/cms-ca-certs" : "—"; public string PreviewSecret => Valid ? $"{Abbrev}-cms-db-password": "—"; + public string PreviewSecretUser => Valid ? $"{Abbrev}-cms-db-user" : "—"; + public string PreviewSecretHost => "global_mysql_host"; + public string PreviewSecretPort => "global_mysql_port"; public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—"; - public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms" : "—"; + public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms_user" : "—"; public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—"; private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant(); @@ -74,7 +84,7 @@ public partial class CreateInstanceViewModel : ObservableObject { _services = services; _ = LoadHostsAsync(); - _ = LoadCifsDefaultsAsync(); + _ = LoadNfsDefaultsAsync(); } partial void OnCustomerAbbrevChanged(string value) => RefreshPreview(); @@ -93,6 +103,9 @@ public partial class CreateInstanceViewModel : ObservableObject OnPropertyChanged(nameof(PreviewVolUserscripts)); OnPropertyChanged(nameof(PreviewVolCaCerts)); OnPropertyChanged(nameof(PreviewSecret)); + OnPropertyChanged(nameof(PreviewSecretUser)); + OnPropertyChanged(nameof(PreviewSecretHost)); + OnPropertyChanged(nameof(PreviewSecretPort)); OnPropertyChanged(nameof(PreviewMySqlDb)); OnPropertyChanged(nameof(PreviewMySqlUser)); OnPropertyChanged(nameof(PreviewCmsUrl)); @@ -106,16 +119,138 @@ public partial class CreateInstanceViewModel : ObservableObject AvailableHosts = new ObservableCollection(hosts); } - private async Task LoadCifsDefaultsAsync() + private async Task LoadNfsDefaultsAsync() { using var scope = _services.CreateScope(); var settings = scope.ServiceProvider.GetRequiredService(); - CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? 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"); + NfsServer = await settings.GetAsync(SettingsService.NfsServer) ?? string.Empty; + NfsExport = await settings.GetAsync(SettingsService.NfsExport) ?? string.Empty; + NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder) ?? string.Empty; + NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions) ?? string.Empty; + } + + [RelayCommand] + private async Task LoadYmlPreviewAsync() + { + if (!Valid) + { + PreviewYml = "# Abbreviation must be exactly 3 lowercase letters (a-z) before loading the YML preview."; + return; + } + + IsLoadingYml = true; + try + { + using var scope = _services.CreateScope(); + var settings = scope.ServiceProvider.GetRequiredService(); + var git = scope.ServiceProvider.GetRequiredService(); + var composer = scope.ServiceProvider.GetRequiredService(); + + var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl); + var repoPat = await settings.GetAsync(SettingsService.GitRepoPat); + if (string.IsNullOrWhiteSpace(repoUrl)) + { + PreviewYml = "# Git template repository URL is not configured. Set it in Settings → Git Repo URL."; + return; + } + + var templateConfig = await git.FetchAsync(repoUrl, repoPat); + + var abbrev = Abbrev; + var stackName = $"{abbrev}-cms-stack"; + + var mySqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost"); + var mySqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306"); + var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev); + var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev); + + var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev); + var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev); + + var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty); + var smtpUsername = await settings.GetAsync(SettingsService.SmtpUsername, string.Empty); + var smtpPassword = await settings.GetAsync(SettingsService.SmtpPassword, string.Empty); + var smtpUseTls = await settings.GetAsync(SettingsService.SmtpUseTls, "YES"); + var smtpUseStartTls = await settings.GetAsync(SettingsService.SmtpUseStartTls, "YES"); + var smtpRewriteDomain = await settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty); + var smtpHostname = await settings.GetAsync(SettingsService.SmtpHostname, string.Empty); + var smtpFromLineOverride = await settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO"); + + var pangolinEndpoint = await settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"); + + var cmsImage = await settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"); + var newtImage = await settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"); + var memcachedImage = await settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"); + var quickChartImage = await settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart"); + + var phpPostMaxSize = await settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"); + var phpUploadMaxFilesize = await settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"); + var phpMaxExecutionTime = await settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); + + // Use form values; fall back to saved global settings + var nfsServer = string.IsNullOrWhiteSpace(NfsServer) ? await settings.GetAsync(SettingsService.NfsServer) : NfsServer; + var nfsExport = string.IsNullOrWhiteSpace(NfsExport) ? await settings.GetAsync(SettingsService.NfsExport) : NfsExport; + var nfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? await settings.GetAsync(SettingsService.NfsExportFolder) : NfsExportFolder; + var nfsOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? await settings.GetAsync(SettingsService.NfsOptions, string.Empty) : NfsExtraOptions; + + var ctx = new RenderContext + { + CustomerName = CustomerName.Trim(), + CustomerAbbrev = abbrev, + StackName = stackName, + CmsServerName = cmsServerName, + HostHttpPort = 80, + CmsImage = cmsImage, + MemcachedImage = memcachedImage, + QuickChartImage = quickChartImage, + NewtImage = newtImage, + ThemeHostPath = themePath, + MySqlHost = mySqlHost, + MySqlPort = mySqlPort, + MySqlDatabase = mySqlDbName, + MySqlUser = mySqlUser, + SmtpServer = smtpServer, + SmtpUsername = smtpUsername, + SmtpPassword = smtpPassword, + SmtpUseTls = smtpUseTls, + SmtpUseStartTls = smtpUseStartTls, + SmtpRewriteDomain = smtpRewriteDomain, + SmtpHostname = smtpHostname, + SmtpFromLineOverride = smtpFromLineOverride, + PhpPostMaxSize = phpPostMaxSize, + PhpUploadMaxFilesize = phpUploadMaxFilesize, + PhpMaxExecutionTime = phpMaxExecutionTime, + PangolinEndpoint = pangolinEndpoint, + NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(), + NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(), + NfsServer = nfsServer, + NfsExport = nfsExport, + NfsExportFolder = nfsExportFolder, + NfsExtraOptions = nfsOptions, + }; + + PreviewYml = composer.Render(templateConfig.Yaml, ctx); + } + catch (Exception ex) + { + PreviewYml = $"# Error rendering YML preview:\n# {ex.Message}"; + } + finally + { + IsLoadingYml = false; + } + } + + [RelayCommand] + private async Task CopyYmlAsync() + { + if (string.IsNullOrEmpty(PreviewYml)) return; + var mainWindow = (Application.Current?.ApplicationLifetime + as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + if (mainWindow is null) return; + var clipboard = TopLevel.GetTopLevel(mainWindow)?.Clipboard; + if (clipboard is not null) + await clipboard.SetTextAsync(PreviewYml); } [RelayCommand] @@ -145,7 +280,8 @@ public partial class CreateInstanceViewModel : ObservableObject try { - // Wire SSH host into docker services + // Wire SSH host into docker services (singletons must know the target host before + // InstanceService uses them internally for secrets and CLI operations) var dockerCli = _services.GetRequiredService(); dockerCli.SetHost(SelectedSshHost); var dockerSecrets = _services.GetRequiredService(); @@ -154,38 +290,12 @@ public partial class CreateInstanceViewModel : ObservableObject using var scope = _services.CreateScope(); var instanceSvc = scope.ServiceProvider.GetRequiredService(); - // ── Step 1: Clone template repo ──────────────────────────────── - SetProgress(10, "Cloning template repository..."); - // Handled inside InstanceService.CreateInstanceAsync - - // ── Step 2: Generate MySQL password → Docker secret ──────────── - SetProgress(20, "Generating secrets..."); - var mysqlPassword = GenerateRandomPassword(32); - - // ── Step 3: Create MySQL database + user via direct TCP ──────── - SetProgress(35, "Creating MySQL database and user..."); - var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync( - Abbrev, - mysqlPassword); - - AppendOutput($"[MySQL] {mysqlMsg}"); - if (!mysqlOk) - { - StatusMessage = mysqlMsg; - return; - } - - // ── Step 4: Create Docker Swarm secret ──────────────────────── - SetProgress(50, "Creating Docker Swarm secrets..."); - var secretName = $"{Abbrev}-cms-db-password"; - var (_, secretId) = await dockerSecrets.EnsureSecretAsync(secretName, mysqlPassword); - AppendOutput($"[Secret] {secretName} → {secretId}"); - - // Password is now ONLY on the Swarm — clear from memory - mysqlPassword = string.Empty; - - // ── Step 5: Deploy stack ────────────────────────────────────── - SetProgress(70, "Rendering compose & deploying stack..."); + // InstanceService.CreateInstanceAsync handles the full provisioning flow: + // 1. Clone template repo + // 2. Generate MySQL password → create Docker Swarm secret + // 3. Create MySQL database + SQL user (same password as the secret) + // 4. Render compose YAML → deploy stack + SetProgress(30, "Provisioning instance (MySQL user, secrets, stack)..."); var dto = new CreateInstanceDto { @@ -194,12 +304,10 @@ public partial class CreateInstanceViewModel : ObservableObject SshHostId = SelectedSshHost.Id, NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(), NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(), - CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.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(), + NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(), + NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(), + NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(), + NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(), }; var result = await instanceSvc.CreateInstanceAsync(dto); @@ -236,10 +344,5 @@ public partial class CreateInstanceViewModel : ObservableObject DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text; } - private static string GenerateRandomPassword(int length) - { - const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"; - return RandomNumberGenerator.GetString(chars, length); - } } diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs index 8435ae0..458a86c 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs @@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using OTSSignsOrchestrator.Core.Data; +using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Desktop.Services; @@ -22,6 +23,8 @@ public partial class HostsViewModel : ObservableObject [ObservableProperty] private bool _isEditing; [ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] private bool _isBusy; + [ObservableProperty] private ObservableCollection _remoteNodes = new(); + [ObservableProperty] private string _nodesStatusMessage = string.Empty; // Edit form fields [ObservableProperty] private string _editLabel = string.Empty; @@ -202,6 +205,36 @@ public partial class HostsViewModel : ObservableObject } } + [RelayCommand] + private async Task ListNodesAsync() + { + if (SelectedHost == null) + { + NodesStatusMessage = "Select a host first."; + return; + } + + IsBusy = true; + NodesStatusMessage = $"Listing nodes on {SelectedHost.Label}..."; + try + { + var dockerCli = _services.GetRequiredService(); + dockerCli.SetHost(SelectedHost); + var nodes = await dockerCli.ListNodesAsync(); + RemoteNodes = new ObservableCollection(nodes); + NodesStatusMessage = $"Found {nodes.Count} node(s) on {SelectedHost.Label}."; + } + catch (Exception ex) + { + RemoteNodes.Clear(); + NodesStatusMessage = $"Error: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + [RelayCommand] private async Task TestConnectionAsync() { diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs index 80a1351..5f3ea41 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs @@ -6,181 +6,156 @@ using Microsoft.Extensions.DependencyInjection; using OTSSignsOrchestrator.Core.Data; using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Core.Services; +using OTSSignsOrchestrator.Desktop.Models; using OTSSignsOrchestrator.Desktop.Services; namespace OTSSignsOrchestrator.Desktop.ViewModels; /// /// ViewModel for listing, viewing, and managing CMS instances. +/// All data is fetched live from Docker Swarm hosts — nothing stored locally. /// public partial class InstancesViewModel : ObservableObject { private readonly IServiceProvider _services; - [ObservableProperty] private ObservableCollection _instances = new(); - [ObservableProperty] private CmsInstance? _selectedInstance; + [ObservableProperty] private ObservableCollection _instances = new(); + [ObservableProperty] private LiveStackItem? _selectedInstance; [ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] private bool _isBusy; [ObservableProperty] private string _filterText = string.Empty; - [ObservableProperty] private ObservableCollection _remoteStacks = new(); [ObservableProperty] private ObservableCollection _selectedServices = new(); - // Available SSH hosts for the dropdown + // Available SSH hosts — loaded for display and used to scope operations [ObservableProperty] private ObservableCollection _availableHosts = new(); [ObservableProperty] private SshHost? _selectedSshHost; public InstancesViewModel(IServiceProvider services) { _services = services; - _ = InitAsync(); - } - - private async Task InitAsync() - { - await LoadHostsAsync(); - await LoadInstancesAsync(); + _ = RefreshAllAsync(); } + /// + /// Enumerates all SSH hosts, then calls docker stack ls on each to build the + /// live instance list. Only stacks matching *-cms-stack are shown. + /// [RelayCommand] - private async Task LoadHostsAsync() - { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync(); - AvailableHosts = new ObservableCollection(hosts); - } + private async Task LoadInstancesAsync() => await RefreshAllAsync(); - [RelayCommand] - private async Task LoadInstancesAsync() + private async Task RefreshAllAsync() { IsBusy = true; + StatusMessage = "Loading live instances from all hosts..."; + SelectedServices = new ObservableCollection(); try { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync(); + AvailableHosts = new ObservableCollection(hosts); - var query = db.CmsInstances.Include(i => i.SshHost).AsQueryable(); - if (!string.IsNullOrWhiteSpace(FilterText)) + var dockerCli = _services.GetRequiredService(); + var all = new List(); + var errors = new List(); + + foreach (var host in hosts) { - query = query.Where(i => - i.CustomerName.Contains(FilterText) || - i.StackName.Contains(FilterText)); + try + { + dockerCli.SetHost(host); + var stacks = await dockerCli.ListStacksAsync(); + foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack"))) + { + all.Add(new LiveStackItem + { + StackName = stack.Name, + CustomerAbbrev = stack.Name[..^10], + ServiceCount = stack.ServiceCount, + Host = host, + }); + } + } + catch (Exception ex) { errors.Add($"{host.Label}: {ex.Message}"); } } - var items = await query.OrderByDescending(i => i.CreatedAt).ToListAsync(); - Instances = new ObservableCollection(items); - StatusMessage = $"Loaded {items.Count} instance(s)."; - } - catch (Exception ex) - { - StatusMessage = $"Error: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } + if (!string.IsNullOrWhiteSpace(FilterText)) + all = all.Where(i => + i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) || + i.CustomerAbbrev.Contains(FilterText, StringComparison.OrdinalIgnoreCase) || + i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList(); - [RelayCommand] - private async Task RefreshRemoteStacksAsync() - { - if (SelectedSshHost == null) - { - StatusMessage = "Select an SSH host first."; - return; - } - - IsBusy = true; - StatusMessage = $"Listing stacks on {SelectedSshHost.Label}..."; - try - { - var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(SelectedSshHost); - - var stacks = await dockerCli.ListStacksAsync(); - RemoteStacks = new ObservableCollection(stacks); - StatusMessage = $"Found {stacks.Count} stack(s) on {SelectedSshHost.Label}."; - } - catch (Exception ex) - { - StatusMessage = $"Error listing stacks: {ex.Message}"; - } - finally - { - IsBusy = false; + Instances = new ObservableCollection(all); + var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s)."; + if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}"; + StatusMessage = msg; } + catch (Exception ex) { StatusMessage = $"Error: {ex.Message}"; } + finally { IsBusy = false; } } [RelayCommand] private async Task InspectInstanceAsync() { if (SelectedInstance == null) return; - if (SelectedSshHost == null && SelectedInstance.SshHost == null) - { - StatusMessage = "No SSH host associated with this instance."; - return; - } - IsBusy = true; + StatusMessage = $"Inspecting '{SelectedInstance.StackName}'..."; try { - var host = SelectedInstance.SshHost ?? SelectedSshHost!; var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(host); - + dockerCli.SetHost(SelectedInstance.Host); var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); SelectedServices = new ObservableCollection(services); StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'."; } - catch (Exception ex) - { - StatusMessage = $"Error inspecting: {ex.Message}"; - } - finally - { - IsBusy = false; - } + catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; } + finally { IsBusy = false; } } [RelayCommand] private async Task DeleteInstanceAsync() { if (SelectedInstance == null) return; - IsBusy = true; StatusMessage = $"Deleting {SelectedInstance.StackName}..."; try { - var host = SelectedInstance.SshHost ?? SelectedSshHost; - if (host == null) - { - StatusMessage = "No SSH host available for deletion."; - return; - } - - // Wire up SSH-based docker services var dockerCli = _services.GetRequiredService(); - dockerCli.SetHost(host); + dockerCli.SetHost(SelectedInstance.Host); var dockerSecrets = _services.GetRequiredService(); - dockerSecrets.SetHost(host); - + dockerSecrets.SetHost(SelectedInstance.Host); using var scope = _services.CreateScope(); var instanceSvc = scope.ServiceProvider.GetRequiredService(); - - var result = await instanceSvc.DeleteInstanceAsync(SelectedInstance.Id); + var result = await instanceSvc.DeleteInstanceAsync( + SelectedInstance.StackName, SelectedInstance.CustomerAbbrev); StatusMessage = result.Success ? $"Instance '{SelectedInstance.StackName}' deleted." : $"Delete failed: {result.ErrorMessage}"; + await RefreshAllAsync(); + } + catch (Exception ex) { StatusMessage = $"Error deleting: {ex.Message}"; } + finally { IsBusy = false; } + } - await LoadInstancesAsync(); - } - catch (Exception ex) + [RelayCommand] + private async Task RotateMySqlPasswordAsync() + { + if (SelectedInstance == null) return; + IsBusy = true; + StatusMessage = $"Rotating MySQL password for {SelectedInstance.StackName}..."; + try { - StatusMessage = $"Error deleting: {ex.Message}"; - } - finally - { - IsBusy = false; + var dockerCli = _services.GetRequiredService(); + dockerCli.SetHost(SelectedInstance.Host); + var dockerSecrets = _services.GetRequiredService(); + dockerSecrets.SetHost(SelectedInstance.Host); + using var scope = _services.CreateScope(); + var instanceSvc = scope.ServiceProvider.GetRequiredService(); + var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(SelectedInstance.StackName); + StatusMessage = ok ? $"Done: {msg}" : $"Failed: {msg}"; + await RefreshAllAsync(); } + catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; } + finally { IsBusy = false; } } } diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs index 41ba8f6..6831499 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs @@ -36,7 +36,6 @@ public partial class LogsViewModel : ObservableObject var db = scope.ServiceProvider.GetRequiredService(); var items = await db.OperationLogs - .Include(l => l.Instance) .OrderByDescending(l => l.Timestamp) .Take(MaxEntries) .ToListAsync(); diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs index 987ab11..ec563be 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs @@ -2,13 +2,12 @@ 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; /// -/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, CIFS, +/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, NFS, /// and Instance Defaults configuration, persisted via SettingsService. /// public partial class SettingsViewModel : ObservableObject @@ -41,13 +40,11 @@ public partial class SettingsViewModel : ObservableObject // ── Pangolin ──────────────────────────────────────────────────────────── [ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net"; - // ── CIFS ──────────────────────────────────────────────────────────────── - [ObservableProperty] private string _cifsServer = 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"; + // ── NFS ───────────────────────────────────────────────────────────────── + [ObservableProperty] private string _nfsServer = string.Empty; + [ObservableProperty] private string _nfsExport = string.Empty; + [ObservableProperty] private string _nfsExportFolder = string.Empty; + [ObservableProperty] private string _nfsOptions = string.Empty; // ── Instance Defaults ─────────────────────────────────────────────────── [ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3"; @@ -57,7 +54,7 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string _defaultCmsServerNameTemplate = "{abbrev}.ots-signs.com"; [ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom"; [ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db"; - [ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms"; + [ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user"; [ObservableProperty] private string _defaultPhpPostMaxSize = "10G"; [ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G"; [ObservableProperty] private string _defaultPhpMaxExecutionTime = "600"; @@ -100,13 +97,11 @@ public partial class SettingsViewModel : ObservableObject // Pangolin PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"); - // CIFS - CifsServer = await svc.GetAsync(SettingsService.CifsServer, 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"); + // NFS + NfsServer = await svc.GetAsync(SettingsService.NfsServer, string.Empty); + NfsExport = await svc.GetAsync(SettingsService.NfsExport, string.Empty); + NfsExportFolder = await svc.GetAsync(SettingsService.NfsExportFolder, string.Empty); + NfsOptions = await svc.GetAsync(SettingsService.NfsOptions, string.Empty); // Instance Defaults DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"); @@ -116,7 +111,7 @@ public partial class SettingsViewModel : ObservableObject DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com"); DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom"); DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db"); - DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms"); + DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user"); DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"); DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"); DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); @@ -167,13 +162,11 @@ public partial class SettingsViewModel : ObservableObject // Pangolin (SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false), - // CIFS - (SettingsService.CifsServer, NullIfEmpty(CifsServer), 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), + // NFS + (SettingsService.NfsServer, NullIfEmpty(NfsServer), SettingsService.CatNfs, false), + (SettingsService.NfsExport, NullIfEmpty(NfsExport), SettingsService.CatNfs, false), + (SettingsService.NfsExportFolder, NullIfEmpty(NfsExportFolder), SettingsService.CatNfs, false), + (SettingsService.NfsOptions, NullIfEmpty(NfsOptions), SettingsService.CatNfs, false), // Instance Defaults (SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false), @@ -218,32 +211,21 @@ public partial class SettingsViewModel : ObservableObject if (!int.TryParse(MySqlPort, out var port)) port = 3306; - var csb = new MySqlConnectionStringBuilder - { - Server = MySqlHost, - Port = (uint)port, - UserID = MySqlAdminUser, - Password = MySqlAdminPassword, - ConnectionTimeout = 10, - SslMode = MySqlSslMode.Preferred, - }; - - await using var connection = new MySqlConnection(csb.ConnectionString); - await connection.OpenAsync(); + var docker = _services.GetRequiredService(); + var (connection, tunnel) = await docker.OpenMySqlConnectionAsync( + MySqlHost, port, MySqlAdminUser, MySqlAdminPassword); + await using var _ = connection; + using var __ = tunnel; 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}"; + StatusMessage = $"MySQL connection successful ({MySqlHost}:{port} via SSH tunnel)."; } catch (Exception ex) { - StatusMessage = $"MySQL test error: {ex.Message}"; + StatusMessage = $"MySQL connection failed: {ex.Message}"; } finally { diff --git a/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml b/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml index 191a5a9..52ef7b2 100644 --- a/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml +++ b/OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml @@ -48,26 +48,20 @@ - - + + - - + + - - + + - - + + - - - - - - - - + + @@ -102,46 +96,95 @@ IsVisible="{Binding DeployOutput.Length}" /> - - - - + + - - + + + + + - - - - - + + - - + + + + + - - - - - - + + - - + + + + + + - - - + + + + + - - - - + + + + + + + + + + + + + + + + +