Add WAL file for database and log instance deployment failures
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
Matt Batchelder
2026-02-19 08:27:54 -05:00
parent 4a903bfd2a
commit adf1a2e4db
41 changed files with 2789 additions and 1297 deletions

Submodule .template-cache/2dc03e2b2b45fef3 updated: 07ab87bc65...292fbb4bfe

View File

@@ -8,12 +8,34 @@ public static class AppConstants
public const string AdminRole = "Admin"; public const string AdminRole = "Admin";
public const string ViewerRole = "Viewer"; public const string ViewerRole = "Viewer";
// ── Global Docker secret names ──────────────────────────────────────────
/// <summary>Docker secret name for the global SMTP password.</summary> /// <summary>Docker secret name for the global SMTP password.</summary>
public const string GlobalSmtpSecretName = "global_smtp_password"; public const string GlobalSmtpSecretName = "global_smtp_password";
/// <summary>Build a per-customer MySQL password secret name.</summary> /// <summary>Docker secret name for the shared MySQL host address.</summary>
public static string CustomerMysqlSecretName(string customerName) public const string GlobalMysqlHostSecretName = "global_mysql_host";
=> $"{SanitizeName(customerName)}_mysql_password";
/// <summary>Docker secret name for the shared MySQL port.</summary>
public const string GlobalMysqlPortSecretName = "global_mysql_port";
// ── Per-instance Docker secret name builders ────────────────────────────
/// <summary>Build a per-instance MySQL password secret name from the 3-letter abbreviation.</summary>
public static string CustomerMysqlPasswordSecretName(string abbrev)
=> $"{abbrev}-cms-db-password";
/// <summary>Build a per-instance MySQL username secret name from the 3-letter abbreviation.</summary>
public static string CustomerMysqlUserSecretName(string abbrev)
=> $"{abbrev}-cms-db-user";
/// <summary>Returns all per-instance MySQL secret names for a given abbreviation.</summary>
public static string[] AllCustomerMysqlSecretNames(string abbrev)
=> new[]
{
CustomerMysqlPasswordSecretName(abbrev),
CustomerMysqlUserSecretName(abbrev),
};
/// <summary>Sanitize a customer name for use in Docker/secret names.</summary> /// <summary>Sanitize a customer name for use in Docker/secret names.</summary>
public static string SanitizeName(string name) public static string SanitizeName(string name)

View File

@@ -72,5 +72,5 @@ public class InstanceDefaultsOptions
public string LibraryShareSubPath { get; set; } = "{abbrev}-cms-library"; public string LibraryShareSubPath { get; set; } = "{abbrev}-cms-library";
public string MySqlDatabaseTemplate { get; set; } = "{abbrev}_cms_db"; public string MySqlDatabaseTemplate { get; set; } = "{abbrev}_cms_db";
public string MySqlUserTemplate { get; set; } = "{abbrev}_cms"; public string MySqlUserTemplate { get; set; } = "{abbrev}_cms_user";
} }

View File

@@ -15,9 +15,7 @@ public class XiboContext : DbContext
_dataProtection = dataProtection; _dataProtection = dataProtection;
} }
public DbSet<CmsInstance> CmsInstances => Set<CmsInstance>();
public DbSet<SshHost> SshHosts => Set<SshHost>(); public DbSet<SshHost> SshHosts => Set<SshHost>();
public DbSet<SecretMetadata> SecretMetadata => Set<SecretMetadata>();
public DbSet<OperationLog> OperationLogs => Set<OperationLog>(); public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>(); public DbSet<AppSetting> AppSettings => Set<AppSetting>();
@@ -25,32 +23,6 @@ public class XiboContext : DbContext
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
// --- CmsInstance ---
modelBuilder.Entity<CmsInstance>(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<string?, string?>(
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 --- // --- SshHost ---
modelBuilder.Entity<SshHost>(entity => modelBuilder.Entity<SshHost>(entity =>
{ {
@@ -71,17 +43,11 @@ public class XiboContext : DbContext
} }
}); });
// --- SecretMetadata ---
modelBuilder.Entity<SecretMetadata>(entity =>
{
entity.HasIndex(e => e.Name).IsUnique();
});
// --- OperationLog --- // --- OperationLog ---
modelBuilder.Entity<OperationLog>(entity => modelBuilder.Entity<OperationLog>(entity =>
{ {
entity.HasIndex(e => e.Timestamp); entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => e.InstanceId); entity.HasIndex(e => e.StackName);
entity.HasIndex(e => e.Operation); entity.HasIndex(e => e.Operation);
}); });

View File

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

View File

@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class ReplaceCifsWithNfs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Add new NFS columns
migrationBuilder.AddColumn<string>(
name: "NfsServer",
table: "CmsInstances",
type: "TEXT",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NfsExport",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NfsExportFolder",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Re-add CIFS columns
migrationBuilder.AddColumn<string>(
name: "CifsServer", table: "CmsInstances", type: "TEXT", maxLength: 200, nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsShareName", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsShareFolder", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsUsername", table: "CmsInstances", type: "TEXT", maxLength: 200, nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsPassword", table: "CmsInstances", type: "TEXT", maxLength: 1000, nullable: true);
migrationBuilder.AddColumn<string>(
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");
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260219121529_RemoveCmsInstancesAndSecretMetadata")]
partial class RemoveCmsInstancesAndSecretMetadata
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<string>("StackName")
.HasMaxLength(150)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Operation");
b.HasIndex("StackName");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,159 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class RemoveCmsInstancesAndSecretMetadata : Migration
{
/// <inheritdoc />
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<string>(
name: "StackName",
table: "OperationLogs",
type: "TEXT",
maxLength: 150,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_StackName",
table: "OperationLogs",
column: "StackName");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_OperationLogs_StackName",
table: "OperationLogs");
migrationBuilder.DropColumn(
name: "StackName",
table: "OperationLogs");
migrationBuilder.AddColumn<Guid>(
name: "InstanceId",
table: "OperationLogs",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "CmsInstances",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
SshHostId = table.Column<Guid>(type: "TEXT", nullable: true),
CmsServerName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Constraints = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
CustomerAbbrev = table.Column<string>(type: "TEXT", maxLength: 3, nullable: false),
CustomerName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
DeletedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
HostHttpPort = table.Column<int>(type: "INTEGER", nullable: false),
LibraryHostPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
NewtId = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NewtSecret = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NfsExport = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NfsExportFolder = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NfsExtraOptions = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NfsServer = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
SmtpServer = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
SmtpUsername = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
StackName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Status = table.Column<int>(type: "INTEGER", nullable: false),
TemplateCacheKey = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
TemplateLastFetch = table.Column<DateTime>(type: "TEXT", nullable: true),
TemplateRepoPat = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
TemplateRepoUrl = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
ThemeHostPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
XiboApiTestStatus = table.Column<int>(type: "INTEGER", nullable: false),
XiboApiTestedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
XiboPassword = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
XiboUsername = table.Column<string>(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<Guid>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
CustomerName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
IsGlobal = table.Column<bool>(type: "INTEGER", nullable: false),
LastRotatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
Name = table.Column<string>(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");
}
}
}

View File

@@ -45,140 +45,6 @@ namespace OTSSignsOrchestrator.Core.Migrations
b.ToTable("AppSettings"); b.ToTable("AppSettings");
}); });
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CifsExtraOptions")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("CifsServer")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CifsShareFolder")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsShareName")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsUsername")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b => modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -188,9 +54,6 @@ namespace OTSSignsOrchestrator.Core.Migrations
b.Property<long?>("DurationMs") b.Property<long?>("DurationMs")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message") b.Property<string>("Message")
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -198,6 +61,10 @@ namespace OTSSignsOrchestrator.Core.Migrations
b.Property<int>("Operation") b.Property<int>("Operation")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("StackName")
.HasMaxLength(150)
.HasColumnType("TEXT");
b.Property<int>("Status") b.Property<int>("Status")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -210,47 +77,15 @@ namespace OTSSignsOrchestrator.Core.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation"); b.HasIndex("Operation");
b.HasIndex("StackName");
b.HasIndex("Timestamp"); b.HasIndex("Timestamp");
b.ToTable("OperationLogs"); b.ToTable("OperationLogs");
}); });
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b => modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -309,35 +144,6 @@ namespace OTSSignsOrchestrator.Core.Migrations
b.ToTable("SshHosts"); 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 #pragma warning restore 612, 618
} }
} }

View File

@@ -22,24 +22,19 @@ public class CreateInstanceDto
[MaxLength(500)] [MaxLength(500)]
public string? NewtSecret { get; set; } 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)] [MaxLength(200)]
public string? CifsServer { get; set; } public string? NfsServer { get; set; }
/// <summary>NFS export path on the server (e.g. "/srv/nfs").</summary>
[MaxLength(500)]
public string? NfsExport { get; set; }
/// <summary>Optional subfolder within the export (e.g. "ots_cms"). Omit to use the export root.</summary>
[MaxLength(500)]
public string? NfsExportFolder { get; set; }
[MaxLength(500)] [MaxLength(500)]
public string? CifsShareName { get; set; } public string? NfsExtraOptions { get; set; }
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root.</summary>
[MaxLength(500)]
public string? CifsShareFolder { get; set; }
[MaxLength(200)]
public string? CifsUsername { get; set; }
[MaxLength(500)]
public string? CifsPassword { get; set; }
[MaxLength(500)]
public string? CifsExtraOptions { get; set; }
} }

View File

@@ -0,0 +1,16 @@
namespace OTSSignsOrchestrator.Core.Models.DTOs;
/// <summary>
/// Represents a node in the Docker Swarm cluster,
/// parsed from <c>docker node ls</c> output.
/// </summary>
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;
}

View File

@@ -4,6 +4,32 @@ namespace OTSSignsOrchestrator.Core.Models.DTOs;
public class UpdateInstanceDto public class UpdateInstanceDto
{ {
// ── Identity / rendering context (populated from live service inspect) ──
/// <summary>Customer display name (used in log messages and comment header).</summary>
[MaxLength(100)]
public string? CustomerName { get; set; }
/// <summary>
/// 3-letter abbreviation. If null, derived automatically from the stack name
/// by stripping the "-cms-stack" suffix.
/// </summary>
[MaxLength(3)]
public string? CustomerAbbrev { get; set; }
/// <summary>Public hostname for the CMS (e.g. "acm.ots-signs.com").</summary>
[MaxLength(200)]
public string? CmsServerName { get; set; }
/// <summary>Host-side HTTP port (defaults to 80 when null).</summary>
public int? HostHttpPort { get; set; }
/// <summary>Host path bind-mounted as the theme directory.</summary>
[MaxLength(500)]
public string? ThemeHostPath { get; set; }
// ── Optional overrides (null = keep / use global settings) ──
[MaxLength(500)] [MaxLength(500)]
public string? TemplateRepoUrl { get; set; } public string? TemplateRepoUrl { get; set; }
@@ -18,30 +44,29 @@ public class UpdateInstanceDto
public List<string>? Constraints { get; set; } public List<string>? Constraints { get; set; }
[MaxLength(200)] // ── NFS volume settings (per-instance) ──
public string? XiboUsername { get; set; }
[MaxLength(200)] [MaxLength(200)]
public string? XiboPassword { get; set; } public string? NfsServer { get; set; }
// ── CIFS / SMB credentials (per-instance) ── /// <summary>NFS export path on the server (e.g. "/srv/nfs").</summary>
[MaxLength(500)]
public string? NfsExport { get; set; }
[MaxLength(200)] /// <summary>Optional subfolder within the export (e.g. "ots_cms"). Omit to use the export root.</summary>
public string? CifsServer { get; set; } [MaxLength(500)]
public string? NfsExportFolder { get; set; }
[MaxLength(500)] [MaxLength(500)]
public string? CifsShareName { get; set; } public string? NfsExtraOptions { get; set; }
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root.</summary> // ── Pangolin / Newt tunnel settings ──
/// <summary>Pangolin Newt ID for the tunnel service.</summary>
[MaxLength(500)] [MaxLength(500)]
public string? CifsShareFolder { get; set; } public string? NewtId { get; set; }
[MaxLength(200)]
public string? CifsUsername { get; set; }
/// <summary>Pangolin Newt Secret for the tunnel service.</summary>
[MaxLength(500)] [MaxLength(500)]
public string? CifsPassword { get; set; } public string? NewtSecret { get; set; }
[MaxLength(500)]
public string? CifsExtraOptions { get; set; }
} }

View File

@@ -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;
/// <summary>Exactly 3 lowercase letters used to derive all resource names.</summary>
[MaxLength(3)]
public string CustomerAbbrev { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string StackName { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string CmsServerName { get; set; } = string.Empty;
[Required, Range(1024, 65535)]
public int HostHttpPort { get; set; }
[Required, MaxLength(500)]
public string ThemeHostPath { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string LibraryHostPath { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string SmtpServer { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string SmtpUsername { get; set; } = string.Empty;
/// <summary>
/// JSON array of placement constraints, e.g. ["node.labels.xibo==true"]
/// </summary>
[MaxLength(2000)]
public string? Constraints { get; set; }
[Required, MaxLength(500)]
public string TemplateRepoUrl { get; set; } = string.Empty;
[MaxLength(500)]
public string? TemplateRepoPat { get; set; }
public DateTime? TemplateLastFetch { get; set; }
[MaxLength(100)]
public string? TemplateCacheKey { get; set; }
public InstanceStatus Status { get; set; } = InstanceStatus.Deploying;
/// <summary>Encrypted Xibo admin username for API access.</summary>
[MaxLength(500)]
public string? XiboUsername { get; set; }
/// <summary>Encrypted Xibo admin password. Never logged; encrypted at rest.</summary>
[MaxLength(1000)]
public string? XiboPassword { get; set; }
public XiboApiTestStatus XiboApiTestStatus { get; set; } = XiboApiTestStatus.Unknown;
public DateTime? XiboApiTestedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Soft delete marker.</summary>
public DateTime? DeletedAt { get; set; }
// ── CIFS / SMB credentials (per-instance) ─────────────────────────────
[MaxLength(200)]
public string? CifsServer { get; set; }
[MaxLength(500)]
public string? CifsShareName { get; set; }
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root.</summary>
[MaxLength(500)]
public string? CifsShareFolder { get; set; }
[MaxLength(200)]
public string? CifsUsername { get; set; }
/// <summary>Encrypted CIFS password. Never logged; encrypted at rest.</summary>
[MaxLength(1000)]
public string? CifsPassword { get; set; }
[MaxLength(500)]
public string? CifsExtraOptions { get; set; }
/// <summary>ID of the SshHost this instance is deployed to.</summary>
public Guid? SshHostId { get; set; }
[ForeignKey(nameof(SshHostId))]
public SshHost? SshHost { get; set; }
public ICollection<OperationLog> OperationLogs { get; set; } = new List<OperationLog>();
}

View File

@@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OTSSignsOrchestrator.Core.Models.Entities; namespace OTSSignsOrchestrator.Core.Models.Entities;
@@ -28,10 +27,9 @@ public class OperationLog
public OperationType Operation { get; set; } public OperationType Operation { get; set; }
public Guid? InstanceId { get; set; } /// <summary>Name of the Docker stack this operation relates to (e.g. "acm-cms-stack").</summary>
[MaxLength(150)]
[ForeignKey(nameof(InstanceId))] public string? StackName { get; set; }
public CmsInstance? Instance { get; set; }
[MaxLength(200)] [MaxLength(200)]
public string? UserId { get; set; } public string? UserId { get; set; }

View File

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

View File

@@ -54,6 +54,4 @@ public class SshHost
public DateTime? LastTestedAt { get; set; } public DateTime? LastTestedAt { get; set; }
public bool? LastTestSuccess { get; set; } public bool? LastTestSuccess { get; set; }
public ICollection<CmsInstance> Instances { get; set; } = new List<CmsInstance>();
} }

View File

@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace OTSSignsOrchestrator.Core.Services; namespace OTSSignsOrchestrator.Core.Services;
@@ -28,7 +29,8 @@ public class ComposeRenderService
if (string.IsNullOrWhiteSpace(templateYaml)) if (string.IsNullOrWhiteSpace(templateYaml))
throw new ArgumentException("Template YAML is empty. Ensure template.yml exists in the configured git repository."); 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 return templateYaml
.Replace("{{ABBREV}}", ctx.CustomerAbbrev) .Replace("{{ABBREV}}", ctx.CustomerAbbrev)
@@ -59,40 +61,87 @@ public class ComposeRenderService
.Replace("{{PANGOLIN_ENDPOINT}}", ctx.PangolinEndpoint) .Replace("{{PANGOLIN_ENDPOINT}}", ctx.PangolinEndpoint)
.Replace("{{NEWT_ID}}", ctx.NewtId ?? "CONFIGURE_ME") .Replace("{{NEWT_ID}}", ctx.NewtId ?? "CONFIGURE_ME")
.Replace("{{NEWT_SECRET}}", ctx.NewtSecret ?? "CONFIGURE_ME") .Replace("{{NEWT_SECRET}}", ctx.NewtSecret ?? "CONFIGURE_ME")
.Replace("{{CIFS_SERVER}}", (ctx.CifsServer ?? string.Empty).TrimEnd('/')) .Replace("{{NFS_DEVICE_PREFIX}}", nfsDevicePrefix)
.Replace("{{CIFS_SHARE_NAME}}", BuildSharePath(ctx.CifsShareName, ctx.CifsShareFolder)) .Replace("{{NFS_OPTS}}", nfsOpts)
// Legacy token — was a path component (e.g. "/sharename"), so templates concatenate // ── Legacy CIFS token compatibility ─────────────────────────────
// it directly after the server: //{{CIFS_SERVER}}{{CIFS_SHARE_BASE_PATH}}/... // External git template repos may still contain old CIFS tokens.
// We must keep the leading "/" to produce a valid device path. // Map them to NFS equivalents so those templates render correctly.
.Replace("{{CIFS_SHARE_BASE_PATH}}", "/" + BuildSharePath(ctx.CifsShareName, ctx.CifsShareFolder)) .Replace("{{CIFS_SERVER}}", ctx.NfsServer ?? string.Empty)
.Replace("{{CIFS_USERNAME}}", ctx.CifsUsername ?? string.Empty) .Replace("{{CIFS_SHARE_NAME}}", BuildLegacySharePath(ctx))
.Replace("{{CIFS_PASSWORD}}", ctx.CifsPassword ?? string.Empty) .Replace("{{CIFS_SHARE_BASE_PATH}}", "/" + BuildLegacySharePath(ctx))
.Replace("{{CIFS_OPTS}}", cifsOpts); .Replace("{{CIFS_USERNAME}}", string.Empty)
.Replace("{{CIFS_PASSWORD}}", string.Empty)
.Replace("{{CIFS_OPTS}}", nfsOpts);
} }
private static string BuildCifsOpts(RenderContext ctx) /// <summary>
/// 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.
/// </summary>
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}";
}
/// <summary>
/// Builds the NFS mount options string for Docker volume driver_opts.
/// Format: "addr=&lt;server&gt;,nfsvers=4,proto=tcp[,extraOptions]".
/// </summary>
private static string BuildNfsOpts(RenderContext ctx)
{
if (string.IsNullOrWhiteSpace(ctx.NfsServer))
return string.Empty; return string.Empty;
// vers=3.0 is required by most modern SMB servers (e.g. Hetzner Storage Box). var opts = $"addr={ctx.NfsServer},nfsvers=4,proto=tcp";
// Without it, mount.cifs may negotiate SMBv1/2.1 which gets rejected as "permission denied". if (!string.IsNullOrWhiteSpace(ctx.NfsExtraOptions))
var opts = $"addr={ctx.CifsServer},username={ctx.CifsUsername},password={ctx.CifsPassword},vers=3.0"; opts += $",{ctx.NfsExtraOptions}";
if (!string.IsNullOrWhiteSpace(ctx.CifsExtraOptions))
opts += $",{ctx.CifsExtraOptions}";
return opts; return opts;
} }
/// <summary> /// <summary>
/// Combines share name and optional subfolder into a single path segment. /// Builds the NFS device prefix used in volume definitions.
/// e.g. ("u548897-sub1", "ots_cms") → "u548897-sub1/ots_cms" /// Format: ":/export[/subfolder]" (the colon is part of the device path for NFS).
/// ("u548897-sub1", null) → "u548897-sub1" /// e.g. ":/srv/nfs/ots_cms" or ":/srv/nfs".
/// </summary> /// </summary>
private static string BuildSharePath(string? shareName, string? shareFolder) private static string BuildNfsDevicePrefix(RenderContext ctx)
{ {
var name = (shareName ?? string.Empty).Trim('/'); var export = (ctx.NfsExport ?? string.Empty).Trim('/');
var folder = (shareFolder ?? string.Empty).Trim('/'); var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/');
return string.IsNullOrEmpty(folder) ? name : $"{name}/{folder}"; var path = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
return $":/{path}";
}
/// <summary>
/// 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 <c>ots/cms-custom</c> vs flat <c>ots-cms-custom</c>).
/// </summary>
public static List<string> 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<string>();
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;
} }
/// <summary> /// <summary>
@@ -134,6 +183,9 @@ public class ComposeRenderService
CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}" CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}"
secrets: secrets:
- {{ABBREV}}-cms-db-password - {{ABBREV}}-cms-db-password
- {{ABBREV}}-cms-db-user
- global_mysql_host
- global_mysql_port
volumes: volumes:
- {{ABBREV}}-cms-custom:/var/www/cms/custom - {{ABBREV}}-cms-custom:/var/www/cms/custom
- {{ABBREV}}-cms-backup:/var/www/backup - {{ABBREV}}-cms-backup:/var/www/backup
@@ -199,37 +251,43 @@ public class ComposeRenderService
{{ABBREV}}-cms-custom: {{ABBREV}}-cms-custom:
driver: local driver: local
driver_opts: driver_opts:
type: cifs type: nfs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-custom device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-custom"
o: {{CIFS_OPTS}} o: "{{NFS_OPTS}}"
{{ABBREV}}-cms-backup: {{ABBREV}}-cms-backup:
driver: local driver: local
driver_opts: driver_opts:
type: cifs type: nfs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-backup device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-backup"
o: {{CIFS_OPTS}} o: "{{NFS_OPTS}}"
{{ABBREV}}-cms-library: {{ABBREV}}-cms-library:
driver: local driver: local
driver_opts: driver_opts:
type: cifs type: nfs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-library device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-library"
o: {{CIFS_OPTS}} o: "{{NFS_OPTS}}"
{{ABBREV}}-cms-userscripts: {{ABBREV}}-cms-userscripts:
driver: local driver: local
driver_opts: driver_opts:
type: cifs type: nfs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-userscripts device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-userscripts"
o: {{CIFS_OPTS}} o: "{{NFS_OPTS}}"
{{ABBREV}}-cms-ca-certs: {{ABBREV}}-cms-ca-certs:
driver: local driver: local
driver_opts: driver_opts:
type: cifs type: nfs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-ca-certs device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-ca-certs"
o: {{CIFS_OPTS}} o: "{{NFS_OPTS}}"
secrets: secrets:
{{ABBREV}}-cms-db-password: {{ABBREV}}-cms-db-password:
external: true 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? NewtId { get; set; }
public string? NewtSecret { get; set; } public string? NewtSecret { get; set; }
// CIFS volume settings // NFS volume settings
public string? CifsServer { get; set; } public string? NfsServer { get; set; }
public string? CifsShareName { get; set; } /// <summary>NFS export path on the server (e.g. "/srv/nfs" or "/export/data").</summary>
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Empty/null = share root.</summary> public string? NfsExport { get; set; }
public string? CifsShareFolder { get; set; } /// <summary>Optional subfolder within the export (e.g. "ots_cms"). Empty/null = export root.</summary>
public string? CifsUsername { get; set; } public string? NfsExportFolder { get; set; }
public string? CifsPassword { get; set; } /// <summary>Additional NFS mount options appended after the defaults (nfsvers=4,proto=tcp).</summary>
public string? CifsExtraOptions { get; set; } public string? NfsExtraOptions { get; set; }
} }

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Configuration;
using YamlDotNet.RepresentationModel; using YamlDotNet.RepresentationModel;
namespace OTSSignsOrchestrator.Core.Services; namespace OTSSignsOrchestrator.Core.Services;
@@ -91,6 +92,11 @@ public class ComposeValidationService
if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secretsNode) if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secretsNode)
{ {
var presentSecrets = secretsNode.Children.Keys
.OfType<YamlScalarNode>()
.Select(k => k.Value!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in secretsNode.Children) foreach (var (key, value) in secretsNode.Children)
{ {
if (value is YamlMappingNode secretNode) if (value is YamlMappingNode secretNode)
@@ -99,6 +105,23 @@ public class ComposeValidationService
warnings.Add($"Secret '{(key as YamlScalarNode)?.Value}' is not external."); 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 }; return new ValidationResult { Errors = errors, Warnings = warnings };

View File

@@ -1,3 +1,4 @@
using MySqlConnector;
using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.DTOs;
namespace OTSSignsOrchestrator.Core.Services; namespace OTSSignsOrchestrator.Core.Services;
@@ -17,25 +18,75 @@ public interface IDockerCliService
Task<bool> EnsureDirectoryAsync(string path); Task<bool> EnsureDirectoryAsync(string path);
/// <summary> /// <summary>
/// Ensures the required folders exist on an SMB/CIFS share, creating any that are missing. /// Ensures the required folders exist on an NFS export, creating any that are missing.
/// If <paramref name="cifsShareFolder"/> is non-empty, creates it first as a subfolder of the share, /// If <paramref name="nfsExportFolder"/> is non-empty, creates it first as a subfolder of the export,
/// then creates the volume folders inside it. /// 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.
/// </summary> /// </summary>
Task<bool> EnsureSmbFoldersAsync( Task<bool> EnsureNfsFoldersAsync(
string cifsServer, string nfsServer,
string cifsShareName, string nfsExport,
string cifsUsername,
string cifsPassword,
IEnumerable<string> folderNames, IEnumerable<string> folderNames,
string? cifsShareFolder = null); string? nfsExportFolder = null);
/// <summary>
/// Same as <see cref="EnsureNfsFoldersAsync"/> but returns the error message on failure
/// so callers can surface actionable diagnostics.
/// </summary>
Task<(bool Success, string? Error)> EnsureNfsFoldersWithErrorAsync(
string nfsServer,
string nfsExport,
IEnumerable<string> folderNames,
string? nfsExportFolder = null);
/// <summary> /// <summary>
/// Removes all Docker volumes whose names start with <paramref name="stackName"/>_. /// Removes all Docker volumes whose names start with <paramref name="stackName"/>_.
/// Volumes currently in use by running containers will be skipped. /// 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.
/// </summary> /// </summary>
Task<bool> RemoveStackVolumesAsync(string stackName); Task<bool> RemoveStackVolumesAsync(string stackName);
/// <summary>
/// Lists all nodes in the Docker Swarm cluster.
/// Must be executed against a Swarm manager node.
/// </summary>
Task<List<NodeInfo>> ListNodesAsync();
/// <summary>
/// Force-updates a service so all its tasks are restarted and pick up any changed
/// secrets or config (equivalent to docker service update --force).
/// </summary>
Task<bool> ForceUpdateServiceAsync(string serviceName);
/// <summary>
/// Opens a <see cref="MySqlConnection"/> to a remote MySQL server through the
/// implementation's transport (e.g. an SSH tunnel). The caller must dispose
/// both the connection <b>and</b> the returned <c>tunnel</c> handle when finished.
/// </summary>
/// <returns>
/// A tuple of (connection, tunnel). <c>tunnel</c> is <see cref="IDisposable"/>
/// and MUST be disposed after the connection is closed.
/// </returns>
Task<(MySqlConnection Connection, IDisposable Tunnel)> OpenMySqlConnectionAsync(
string mysqlHost, int port,
string adminUser, string adminPassword);
/// <summary>
/// Executes <c>ALTER USER … IDENTIFIED BY …</c> on a remote MySQL server via
/// <see cref="OpenMySqlConnectionAsync"/>.
/// </summary>
Task<(bool Success, string Error)> AlterMySqlUserPasswordAsync(
string mysqlHost, int port,
string adminUser, string adminPassword,
string targetUser, string newPassword);
/// <summary>
/// Atomically swaps one secret reference on a running service:
/// removes <paramref name="oldSecretName"/> and adds <paramref name="newSecretName"/>,
/// preserving the in-container path as <paramref name="targetAlias"/> (defaults to
/// <paramref name="oldSecretName"/> when null, keeping the same /run/secrets/ filename).
/// </summary>
Task<bool> ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null);
} }
public class StackInfo public class StackInfo

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ public class SettingsService
public const string CatMySql = "MySql"; public const string CatMySql = "MySql";
public const string CatSmtp = "Smtp"; public const string CatSmtp = "Smtp";
public const string CatPangolin = "Pangolin"; public const string CatPangolin = "Pangolin";
public const string CatCifs = "Cifs"; public const string CatNfs = "Nfs";
public const string CatDefaults = "Defaults"; public const string CatDefaults = "Defaults";
// ── Key constants ────────────────────────────────────────────────────── // ── Key constants ──────────────────────────────────────────────────────
@@ -49,13 +49,11 @@ public class SettingsService
// Pangolin // Pangolin
public const string PangolinEndpoint = "Pangolin.Endpoint"; public const string PangolinEndpoint = "Pangolin.Endpoint";
// CIFS // NFS
public const string CifsServer = "Cifs.Server"; public const string NfsServer = "Nfs.Server";
public const string CifsShareName = "Cifs.ShareName"; public const string NfsExport = "Nfs.Export";
public const string CifsShareFolder = "Cifs.ShareFolder"; public const string NfsExportFolder = "Nfs.ExportFolder";
public const string CifsUsername = "Cifs.Username"; public const string NfsOptions = "Nfs.Options";
public const string CifsPassword = "Cifs.Password";
public const string CifsOptions = "Cifs.Options";
// Instance Defaults // Instance Defaults
public const string DefaultCmsImage = "Defaults.CmsImage"; public const string DefaultCmsImage = "Defaults.CmsImage";

View File

@@ -0,0 +1,25 @@
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Desktop.Models;
/// <summary>
/// Represents a CMS stack discovered live from a Docker Swarm host.
/// No data is persisted locally — all values come from <c>docker stack ls</c> / inspect.
/// </summary>
public class LiveStackItem
{
/// <summary>Docker stack name, e.g. "acm-cms-stack".</summary>
public string StackName { get; set; } = string.Empty;
/// <summary>3-letter abbreviation derived from the stack name.</summary>
public string CustomerAbbrev { get; set; } = string.Empty;
/// <summary>Number of services reported by <c>docker stack ls</c>.</summary>
public int ServiceCount { get; set; }
/// <summary>The SSH host this stack was found on.</summary>
public SshHost Host { get; set; } = null!;
/// <summary>Label of the host — convenience property for data-binding.</summary>
public string HostLabel => Host?.Label ?? string.Empty;
}

View File

@@ -93,6 +93,31 @@ public class SshConnectionService : IDisposable
}); });
} }
/// <summary>
/// Run a command on the remote host with a timeout.
/// Returns exit code -1 and an error message if the command times out.
/// </summary>
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");
}
});
}
/// <summary> /// <summary>
/// Run a command that requires stdin input (e.g., docker stack deploy --compose-file -). /// Run a command that requires stdin input (e.g., docker stack deploy --compose-file -).
/// </summary> /// </summary>
@@ -171,6 +196,23 @@ public class SshConnectionService : IDisposable
return new SshClient(connInfo); return new SshClient(connInfo);
} }
/// <summary>
/// Opens an SSH local port-forward from 127.0.0.1:&lt;auto&gt; → <paramref name="remoteHost"/>:<paramref name="remotePort"/>
/// through the existing SSH connection for <paramref name="host"/>.
/// The caller must dispose the returned <see cref="ForwardedPortLocal"/> to close the tunnel.
/// </summary>
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() public void Dispose()
{ {
lock (_lock) lock (_lock)

View File

@@ -1,6 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MySqlConnector;
using OTSSignsOrchestrator.Core.Configuration; using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Core.Models.Entities;
@@ -162,66 +163,282 @@ public class SshDockerCliService : IDockerCliService
return exitCode == 0; return exitCode == 0;
} }
public async Task<bool> EnsureSmbFoldersAsync( public async Task<bool> EnsureNfsFoldersAsync(
string cifsServer, string nfsServer,
string cifsShareName, string nfsExport,
string cifsUsername,
string cifsPassword,
IEnumerable<string> folderNames, IEnumerable<string> folderNames,
string? cifsShareFolder = null) string? nfsExportFolder = null)
{ {
EnsureHost(); EnsureHost();
var allSucceeded = true; var exportPath = (nfsExport ?? string.Empty).Trim('/');
var subFolder = (cifsShareFolder ?? string.Empty).Trim('/'); var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
// If a subfolder is specified, ensure it exists first // Build the sub-path beneath the mount point where volume folders will be created
if (!string.IsNullOrEmpty(subFolder)) 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"; _logger.LogInformation(
var (_, mkdirOut, _) = await _ssh.RunCommandAsync(_currentHost!, mkdirCmd); "NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)",
var mkdirOutput = mkdirOut ?? string.Empty; _currentHost.Label, nfsServer, exportPath, subPath, folderList.Count);
}
var alreadyExists = mkdirOutput.Contains("NT_STATUS_OBJECT_NAME_COLLISION", StringComparison.OrdinalIgnoreCase) else
|| mkdirOutput.Contains("already exists", StringComparison.OrdinalIgnoreCase); {
var success = alreadyExists || !mkdirOutput.Contains("NT_STATUS_", StringComparison.OrdinalIgnoreCase); _logger.LogWarning(
"Failed to create NFS export folders on {Host}: {Error}",
if (success) _currentHost.Label, (stderr ?? stdout ?? "unknown error").Trim());
_logger.LogInformation("SMB subfolder ensured: //{Server}/{Share}/{Folder}", cifsServer, cifsShareName, subFolder); return false;
else
{
_logger.LogWarning("Failed to create SMB subfolder //{Server}/{Share}/{Folder}: {Output}",
cifsServer, cifsShareName, subFolder, mkdirOutput.Trim());
allSucceeded = false;
}
} }
// Build the target path prefix for volume folders return true;
var pathPrefix = string.IsNullOrEmpty(subFolder) ? string.Empty : $"{subFolder}/"; }
foreach (var folder in folderNames) public async Task<(bool Success, string? Error)> EnsureNfsFoldersWithErrorAsync(
string nfsServer,
string nfsExport,
IEnumerable<string> 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}"; _logger.LogInformation(
// Run smbclient on the remote Docker host to create the folder on the share. "NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)",
// NT_STATUS_OBJECT_NAME_COLLISION means it already exists — treat as success. _currentHost.Label, nfsServer, exportPath, subPath, folderList.Count);
var cmd = $"smbclient //{cifsServer}/{cifsShareName} -U '{cifsUsername}%{cifsPassword}' -c 'mkdir {targetFolder}' 2>&1"; return (true, null);
var (_, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd);
var output = stdout ?? string.Empty;
var exists = output.Contains("NT_STATUS_OBJECT_NAME_COLLISION", StringComparison.OrdinalIgnoreCase)
|| output.Contains("already exists", StringComparison.OrdinalIgnoreCase);
var ok = exists || !output.Contains("NT_STATUS_", StringComparison.OrdinalIgnoreCase);
if (ok)
_logger.LogInformation("SMB folder ensured: //{Server}/{Share}/{Folder}", cifsServer, cifsShareName, targetFolder);
else
{
_logger.LogWarning("Failed to create SMB folder //{Server}/{Share}/{Folder}: {Output}",
cifsServer, cifsShareName, targetFolder, output.Trim());
allSucceeded = false;
}
} }
return allSucceeded; 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<bool> 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<bool> 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<List<NodeInfo>> 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<NodeInfo>();
var nodeIds = lsOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim())
.Where(id => !string.IsNullOrEmpty(id))
.ToList();
if (nodeIds.Count == 0)
return new List<NodeInfo>();
// 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<NodeInfo>();
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 "<no value>"
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() private void EnsureHost()

View File

@@ -43,7 +43,12 @@ public class SshDockerSecretsService : IDockerSecretsService
if (existing != null && rotate) if (existing != null && rotate)
{ {
_logger.LogInformation("Rotating secret via SSH: {SecretName} (old id={SecretId})", name, existing.Value.id); _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 // Create secret via stdin

View File

@@ -1,5 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Security.Cryptography; using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -35,18 +37,23 @@ public partial class CreateInstanceViewModel : ObservableObject
[ObservableProperty] private string _newtId = string.Empty; [ObservableProperty] private string _newtId = string.Empty;
[ObservableProperty] private string _newtSecret = string.Empty; [ObservableProperty] private string _newtSecret = string.Empty;
// CIFS / SMB credentials (per-instance, defaults loaded from global settings) // NFS volume settings (per-instance, defaults loaded from global settings)
[ObservableProperty] private string _cifsServer = string.Empty; [ObservableProperty] private string _nfsServer = string.Empty;
[ObservableProperty] private string _cifsShareName = string.Empty; [ObservableProperty] private string _nfsExport = string.Empty;
[ObservableProperty] private string _cifsShareFolder = string.Empty; [ObservableProperty] private string _nfsExportFolder = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty; [ObservableProperty] private string _nfsExtraOptions = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
// SSH host selection // SSH host selection
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new(); [ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost; [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 ─────────────────────────────────────────── // ── Derived preview properties ───────────────────────────────────────────
public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—"; public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—";
@@ -55,14 +62,17 @@ public partial class CreateInstanceViewModel : ObservableObject
public string PreviewServiceChart => Valid ? $"{Abbrev}-quickchart" : "—"; public string PreviewServiceChart => Valid ? $"{Abbrev}-quickchart" : "—";
public string PreviewServiceNewt => Valid ? $"{Abbrev}-newt" : "—"; public string PreviewServiceNewt => Valid ? $"{Abbrev}-newt" : "—";
public string PreviewNetwork => Valid ? $"{Abbrev}-net" : "—"; public string PreviewNetwork => Valid ? $"{Abbrev}-net" : "—";
public string PreviewVolCustom => Valid ? $"{Abbrev}-cms-custom" : "—"; public string PreviewVolCustom => Valid ? $"{Abbrev}/cms-custom" : "—";
public string PreviewVolBackup => Valid ? $"{Abbrev}-cms-backup" : "—"; public string PreviewVolBackup => Valid ? $"{Abbrev}/cms-backup" : "—";
public string PreviewVolLibrary => Valid ? $"{Abbrev}-cms-library" : "—"; public string PreviewVolLibrary => Valid ? $"{Abbrev}/cms-library" : "—";
public string PreviewVolUserscripts => Valid ? $"{Abbrev}-cms-userscripts": "—"; public string PreviewVolUserscripts => Valid ? $"{Abbrev}/cms-userscripts": "—";
public string PreviewVolCaCerts => Valid ? $"{Abbrev}-cms-ca-certs" : "—"; public string PreviewVolCaCerts => Valid ? $"{Abbrev}/cms-ca-certs" : "—";
public string PreviewSecret => Valid ? $"{Abbrev}-cms-db-password": "—"; 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 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" : "—"; public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—";
private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant(); private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant();
@@ -74,7 +84,7 @@ public partial class CreateInstanceViewModel : ObservableObject
{ {
_services = services; _services = services;
_ = LoadHostsAsync(); _ = LoadHostsAsync();
_ = LoadCifsDefaultsAsync(); _ = LoadNfsDefaultsAsync();
} }
partial void OnCustomerAbbrevChanged(string value) => RefreshPreview(); partial void OnCustomerAbbrevChanged(string value) => RefreshPreview();
@@ -93,6 +103,9 @@ public partial class CreateInstanceViewModel : ObservableObject
OnPropertyChanged(nameof(PreviewVolUserscripts)); OnPropertyChanged(nameof(PreviewVolUserscripts));
OnPropertyChanged(nameof(PreviewVolCaCerts)); OnPropertyChanged(nameof(PreviewVolCaCerts));
OnPropertyChanged(nameof(PreviewSecret)); OnPropertyChanged(nameof(PreviewSecret));
OnPropertyChanged(nameof(PreviewSecretUser));
OnPropertyChanged(nameof(PreviewSecretHost));
OnPropertyChanged(nameof(PreviewSecretPort));
OnPropertyChanged(nameof(PreviewMySqlDb)); OnPropertyChanged(nameof(PreviewMySqlDb));
OnPropertyChanged(nameof(PreviewMySqlUser)); OnPropertyChanged(nameof(PreviewMySqlUser));
OnPropertyChanged(nameof(PreviewCmsUrl)); OnPropertyChanged(nameof(PreviewCmsUrl));
@@ -106,16 +119,138 @@ public partial class CreateInstanceViewModel : ObservableObject
AvailableHosts = new ObservableCollection<SshHost>(hosts); AvailableHosts = new ObservableCollection<SshHost>(hosts);
} }
private async Task LoadCifsDefaultsAsync() private async Task LoadNfsDefaultsAsync()
{ {
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>(); var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty; NfsServer = await settings.GetAsync(SettingsService.NfsServer) ?? string.Empty;
CifsShareName = await settings.GetAsync(SettingsService.CifsShareName) ?? string.Empty; NfsExport = await settings.GetAsync(SettingsService.NfsExport) ?? string.Empty;
CifsShareFolder = await settings.GetAsync(SettingsService.CifsShareFolder) ?? string.Empty; NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder) ?? string.Empty;
CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty; NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions) ?? string.Empty;
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty; }
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
[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<SettingsService>();
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
var composer = scope.ServiceProvider.GetRequiredService<ComposeRenderService>();
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] [RelayCommand]
@@ -145,7 +280,8 @@ public partial class CreateInstanceViewModel : ObservableObject
try 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<SshDockerCliService>(); var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedSshHost); dockerCli.SetHost(SelectedSshHost);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>(); var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
@@ -154,38 +290,12 @@ public partial class CreateInstanceViewModel : ObservableObject
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>(); var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
// ── Step 1: Clone template repo ──────────────────────────────── // InstanceService.CreateInstanceAsync handles the full provisioning flow:
SetProgress(10, "Cloning template repository..."); // 1. Clone template repo
// Handled inside InstanceService.CreateInstanceAsync // 2. Generate MySQL password → create Docker Swarm secret
// 3. Create MySQL database + SQL user (same password as the secret)
// ── Step 2: Generate MySQL password → Docker secret ──────────── // 4. Render compose YAML → deploy stack
SetProgress(20, "Generating secrets..."); SetProgress(30, "Provisioning instance (MySQL user, secrets, stack)...");
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...");
var dto = new CreateInstanceDto var dto = new CreateInstanceDto
{ {
@@ -194,12 +304,10 @@ public partial class CreateInstanceViewModel : ObservableObject
SshHostId = SelectedSshHost.Id, SshHostId = SelectedSshHost.Id,
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(), NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(), NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(), NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(),
CifsShareName = string.IsNullOrWhiteSpace(CifsShareName) ? null : CifsShareName.Trim(), NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(),
CifsShareFolder = string.IsNullOrWhiteSpace(CifsShareFolder) ? null : CifsShareFolder.Trim(), NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(),
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(), NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(),
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
}; };
var result = await instanceSvc.CreateInstanceAsync(dto); var result = await instanceSvc.CreateInstanceAsync(dto);
@@ -236,10 +344,5 @@ public partial class CreateInstanceViewModel : ObservableObject
DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text; DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text;
} }
private static string GenerateRandomPassword(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
return RandomNumberGenerator.GetString(chars, length);
}
} }

View File

@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data; using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Desktop.Services; using OTSSignsOrchestrator.Desktop.Services;
@@ -22,6 +23,8 @@ public partial class HostsViewModel : ObservableObject
[ObservableProperty] private bool _isEditing; [ObservableProperty] private bool _isEditing;
[ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy; [ObservableProperty] private bool _isBusy;
[ObservableProperty] private ObservableCollection<NodeInfo> _remoteNodes = new();
[ObservableProperty] private string _nodesStatusMessage = string.Empty;
// Edit form fields // Edit form fields
[ObservableProperty] private string _editLabel = string.Empty; [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<SshDockerCliService>();
dockerCli.SetHost(SelectedHost);
var nodes = await dockerCli.ListNodesAsync();
RemoteNodes = new ObservableCollection<NodeInfo>(nodes);
NodesStatusMessage = $"Found {nodes.Count} node(s) on {SelectedHost.Label}.";
}
catch (Exception ex)
{
RemoteNodes.Clear();
NodesStatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand] [RelayCommand]
private async Task TestConnectionAsync() private async Task TestConnectionAsync()
{ {

View File

@@ -6,181 +6,156 @@ using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data; using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services; using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Models;
using OTSSignsOrchestrator.Desktop.Services; using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels; namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary> /// <summary>
/// ViewModel for listing, viewing, and managing CMS instances. /// ViewModel for listing, viewing, and managing CMS instances.
/// All data is fetched live from Docker Swarm hosts — nothing stored locally.
/// </summary> /// </summary>
public partial class InstancesViewModel : ObservableObject public partial class InstancesViewModel : ObservableObject
{ {
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<CmsInstance> _instances = new(); [ObservableProperty] private ObservableCollection<LiveStackItem> _instances = new();
[ObservableProperty] private CmsInstance? _selectedInstance; [ObservableProperty] private LiveStackItem? _selectedInstance;
[ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy; [ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _filterText = string.Empty; [ObservableProperty] private string _filterText = string.Empty;
[ObservableProperty] private ObservableCollection<StackInfo> _remoteStacks = new();
[ObservableProperty] private ObservableCollection<ServiceInfo> _selectedServices = new(); [ObservableProperty] private ObservableCollection<ServiceInfo> _selectedServices = new();
// Available SSH hosts for the dropdown // Available SSH hosts — loaded for display and used to scope operations
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new(); [ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost; [ObservableProperty] private SshHost? _selectedSshHost;
public InstancesViewModel(IServiceProvider services) public InstancesViewModel(IServiceProvider services)
{ {
_services = services; _services = services;
_ = InitAsync(); _ = RefreshAllAsync();
}
private async Task InitAsync()
{
await LoadHostsAsync();
await LoadInstancesAsync();
} }
/// <summary>
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
/// live instance list. Only stacks matching *-cms-stack are shown.
/// </summary>
[RelayCommand] [RelayCommand]
private async Task LoadHostsAsync() private async Task LoadInstancesAsync() => await RefreshAllAsync();
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
AvailableHosts = new ObservableCollection<SshHost>(hosts);
}
[RelayCommand] private async Task RefreshAllAsync()
private async Task LoadInstancesAsync()
{ {
IsBusy = true; IsBusy = true;
StatusMessage = "Loading live instances from all hosts...";
SelectedServices = new ObservableCollection<ServiceInfo>();
try try
{ {
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>(); var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
AvailableHosts = new ObservableCollection<SshHost>(hosts);
var query = db.CmsInstances.Include(i => i.SshHost).AsQueryable(); var dockerCli = _services.GetRequiredService<SshDockerCliService>();
if (!string.IsNullOrWhiteSpace(FilterText)) var all = new List<LiveStackItem>();
var errors = new List<string>();
foreach (var host in hosts)
{ {
query = query.Where(i => try
i.CustomerName.Contains(FilterText) || {
i.StackName.Contains(FilterText)); 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(); if (!string.IsNullOrWhiteSpace(FilterText))
Instances = new ObservableCollection<CmsInstance>(items); all = all.Where(i =>
StatusMessage = $"Loaded {items.Count} instance(s)."; i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
} i.CustomerAbbrev.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
catch (Exception ex) i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList();
{
StatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand] Instances = new ObservableCollection<LiveStackItem>(all);
private async Task RefreshRemoteStacksAsync() var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s).";
{ if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}";
if (SelectedSshHost == null) StatusMessage = msg;
{
StatusMessage = "Select an SSH host first.";
return;
}
IsBusy = true;
StatusMessage = $"Listing stacks on {SelectedSshHost.Label}...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedSshHost);
var stacks = await dockerCli.ListStacksAsync();
RemoteStacks = new ObservableCollection<StackInfo>(stacks);
StatusMessage = $"Found {stacks.Count} stack(s) on {SelectedSshHost.Label}.";
}
catch (Exception ex)
{
StatusMessage = $"Error listing stacks: {ex.Message}";
}
finally
{
IsBusy = false;
} }
catch (Exception ex) { StatusMessage = $"Error: {ex.Message}"; }
finally { IsBusy = false; }
} }
[RelayCommand] [RelayCommand]
private async Task InspectInstanceAsync() private async Task InspectInstanceAsync()
{ {
if (SelectedInstance == null) return; if (SelectedInstance == null) return;
if (SelectedSshHost == null && SelectedInstance.SshHost == null)
{
StatusMessage = "No SSH host associated with this instance.";
return;
}
IsBusy = true; IsBusy = true;
StatusMessage = $"Inspecting '{SelectedInstance.StackName}'...";
try try
{ {
var host = SelectedInstance.SshHost ?? SelectedSshHost!;
var dockerCli = _services.GetRequiredService<SshDockerCliService>(); var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(host); dockerCli.SetHost(SelectedInstance.Host);
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
SelectedServices = new ObservableCollection<ServiceInfo>(services); SelectedServices = new ObservableCollection<ServiceInfo>(services);
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'."; StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
} }
catch (Exception ex) catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; }
{ finally { IsBusy = false; }
StatusMessage = $"Error inspecting: {ex.Message}";
}
finally
{
IsBusy = false;
}
} }
[RelayCommand] [RelayCommand]
private async Task DeleteInstanceAsync() private async Task DeleteInstanceAsync()
{ {
if (SelectedInstance == null) return; if (SelectedInstance == null) return;
IsBusy = true; IsBusy = true;
StatusMessage = $"Deleting {SelectedInstance.StackName}..."; StatusMessage = $"Deleting {SelectedInstance.StackName}...";
try 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<SshDockerCliService>(); var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(host); dockerCli.SetHost(SelectedInstance.Host);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>(); var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(host); dockerSecrets.SetHost(SelectedInstance.Host);
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>(); var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
var result = await instanceSvc.DeleteInstanceAsync(
var result = await instanceSvc.DeleteInstanceAsync(SelectedInstance.Id); SelectedInstance.StackName, SelectedInstance.CustomerAbbrev);
StatusMessage = result.Success StatusMessage = result.Success
? $"Instance '{SelectedInstance.StackName}' deleted." ? $"Instance '{SelectedInstance.StackName}' deleted."
: $"Delete failed: {result.ErrorMessage}"; : $"Delete failed: {result.ErrorMessage}";
await RefreshAllAsync();
}
catch (Exception ex) { StatusMessage = $"Error deleting: {ex.Message}"; }
finally { IsBusy = false; }
}
await LoadInstancesAsync(); [RelayCommand]
} private async Task RotateMySqlPasswordAsync()
catch (Exception ex) {
if (SelectedInstance == null) return;
IsBusy = true;
StatusMessage = $"Rotating MySQL password for {SelectedInstance.StackName}...";
try
{ {
StatusMessage = $"Error deleting: {ex.Message}"; var dockerCli = _services.GetRequiredService<SshDockerCliService>();
} dockerCli.SetHost(SelectedInstance.Host);
finally var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
{ dockerSecrets.SetHost(SelectedInstance.Host);
IsBusy = false; using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
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; }
} }
} }

View File

@@ -36,7 +36,6 @@ public partial class LogsViewModel : ObservableObject
var db = scope.ServiceProvider.GetRequiredService<XiboContext>(); var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var items = await db.OperationLogs var items = await db.OperationLogs
.Include(l => l.Instance)
.OrderByDescending(l => l.Timestamp) .OrderByDescending(l => l.Timestamp)
.Take(MaxEntries) .Take(MaxEntries)
.ToListAsync(); .ToListAsync();

View File

@@ -2,13 +2,12 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using MySqlConnector;
using OTSSignsOrchestrator.Core.Services; using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels; namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary> /// <summary>
/// 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. /// and Instance Defaults configuration, persisted via SettingsService.
/// </summary> /// </summary>
public partial class SettingsViewModel : ObservableObject public partial class SettingsViewModel : ObservableObject
@@ -41,13 +40,11 @@ public partial class SettingsViewModel : ObservableObject
// ── Pangolin ──────────────────────────────────────────────────────────── // ── Pangolin ────────────────────────────────────────────────────────────
[ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net"; [ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net";
// ── CIFS ──────────────────────────────────────────────────────────────── // ── NFS ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _cifsServer = string.Empty; [ObservableProperty] private string _nfsServer = string.Empty;
[ObservableProperty] private string _cifsShareName = string.Empty; [ObservableProperty] private string _nfsExport = string.Empty;
[ObservableProperty] private string _cifsShareFolder = string.Empty; [ObservableProperty] private string _nfsExportFolder = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty; [ObservableProperty] private string _nfsOptions = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
// ── Instance Defaults ─────────────────────────────────────────────────── // ── Instance Defaults ───────────────────────────────────────────────────
[ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3"; [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 _defaultCmsServerNameTemplate = "{abbrev}.ots-signs.com";
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom"; [ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db"; [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 _defaultPhpPostMaxSize = "10G";
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G"; [ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600"; [ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
@@ -100,13 +97,11 @@ public partial class SettingsViewModel : ObservableObject
// Pangolin // Pangolin
PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"); PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
// CIFS // NFS
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty); NfsServer = await svc.GetAsync(SettingsService.NfsServer, string.Empty);
CifsShareName = await svc.GetAsync(SettingsService.CifsShareName, string.Empty); NfsExport = await svc.GetAsync(SettingsService.NfsExport, string.Empty);
CifsShareFolder = await svc.GetAsync(SettingsService.CifsShareFolder, string.Empty); NfsExportFolder = await svc.GetAsync(SettingsService.NfsExportFolder, string.Empty);
CifsUsername = await svc.GetAsync(SettingsService.CifsUsername, string.Empty); NfsOptions = await svc.GetAsync(SettingsService.NfsOptions, string.Empty);
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
// Instance Defaults // Instance Defaults
DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"); 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"); DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom"); DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db"); 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"); DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"); DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
@@ -167,13 +162,11 @@ public partial class SettingsViewModel : ObservableObject
// Pangolin // Pangolin
(SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false), (SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false),
// CIFS // NFS
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false), (SettingsService.NfsServer, NullIfEmpty(NfsServer), SettingsService.CatNfs, false),
(SettingsService.CifsShareName, NullIfEmpty(CifsShareName), SettingsService.CatCifs, false), (SettingsService.NfsExport, NullIfEmpty(NfsExport), SettingsService.CatNfs, false),
(SettingsService.CifsShareFolder, NullIfEmpty(CifsShareFolder), SettingsService.CatCifs, false), (SettingsService.NfsExportFolder, NullIfEmpty(NfsExportFolder), SettingsService.CatNfs, false),
(SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false), (SettingsService.NfsOptions, NullIfEmpty(NfsOptions), SettingsService.CatNfs, false),
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
// Instance Defaults // Instance Defaults
(SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false), (SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false),
@@ -218,32 +211,21 @@ public partial class SettingsViewModel : ObservableObject
if (!int.TryParse(MySqlPort, out var port)) if (!int.TryParse(MySqlPort, out var port))
port = 3306; port = 3306;
var csb = new MySqlConnectionStringBuilder var docker = _services.GetRequiredService<IDockerCliService>();
{ var (connection, tunnel) = await docker.OpenMySqlConnectionAsync(
Server = MySqlHost, MySqlHost, port, MySqlAdminUser, MySqlAdminPassword);
Port = (uint)port, await using var _ = connection;
UserID = MySqlAdminUser, using var __ = tunnel;
Password = MySqlAdminPassword,
ConnectionTimeout = 10,
SslMode = MySqlSslMode.Preferred,
};
await using var connection = new MySqlConnection(csb.ConnectionString);
await connection.OpenAsync();
await using var cmd = connection.CreateCommand(); await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT 1"; cmd.CommandText = "SELECT 1";
await cmd.ExecuteScalarAsync(); await cmd.ExecuteScalarAsync();
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port})."; StatusMessage = $"MySQL connection successful ({MySqlHost}:{port} via SSH tunnel).";
}
catch (MySqlException ex)
{
StatusMessage = $"MySQL connection failed: {ex.Message}";
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"MySQL test error: {ex.Message}"; StatusMessage = $"MySQL connection failed: {ex.Message}";
} }
finally finally
{ {

View File

@@ -48,26 +48,20 @@
</StackPanel> </StackPanel>
</Expander> </Expander>
<!-- SMB / CIFS credentials (per-instance, defaults from global settings) --> <!-- NFS volume settings (per-instance, defaults from global settings) -->
<Expander Header="SMB / CIFS credentials"> <Expander Header="NFS volume settings">
<StackPanel Spacing="8" Margin="0,8,0,0"> <StackPanel Spacing="8" Margin="0,8,0,0">
<TextBlock Text="CIFS Server" FontSize="12" /> <TextBlock Text="NFS Server" FontSize="12" />
<TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" /> <TextBox Text="{Binding NfsServer}" Watermark="e.g. 192.168.1.100" />
<TextBlock Text="Share Name" FontSize="12" /> <TextBlock Text="Export Path" FontSize="12" />
<TextBox Text="{Binding CifsShareName}" Watermark="e.g. u548897-sub1" /> <TextBox Text="{Binding NfsExport}" Watermark="e.g. /srv/nfs" />
<TextBlock Text="Share Folder (optional)" FontSize="12" /> <TextBlock Text="Export Folder (optional)" FontSize="12" />
<TextBox Text="{Binding CifsShareFolder}" Watermark="e.g. ots_cms (leave empty for share root)" /> <TextBox Text="{Binding NfsExportFolder}" Watermark="e.g. ots_cms (leave empty for export root)" />
<TextBlock Text="Username" FontSize="12" /> <TextBlock Text="Extra Mount Options" FontSize="12" />
<TextBox Text="{Binding CifsUsername}" Watermark="SMB username" /> <TextBox Text="{Binding NfsExtraOptions}" Watermark="Additional options after nfsvers=4,proto=tcp" />
<TextBlock Text="Password" FontSize="12" />
<TextBox Text="{Binding CifsPassword}" PasswordChar="●" Watermark="SMB password" />
<TextBlock Text="Extra Options" FontSize="12" />
<TextBox Text="{Binding CifsExtraOptions}" Watermark="file_mode=0777,dir_mode=0777" />
</StackPanel> </StackPanel>
</Expander> </Expander>
@@ -102,46 +96,95 @@
IsVisible="{Binding DeployOutput.Length}" /> IsVisible="{Binding DeployOutput.Length}" />
</StackPanel> </StackPanel>
<!-- ══ RIGHT COLUMN — live resource preview ══ --> <!-- ══ RIGHT COLUMN — tabbed preview ══ -->
<Border Grid.Column="2" <TabControl Grid.Column="2" VerticalAlignment="Top">
Background="#1e1e2e"
CornerRadius="8"
Padding="16,14"
VerticalAlignment="Top">
<StackPanel Spacing="6">
<TextBlock Text="Resource Preview" FontSize="14" FontWeight="SemiBold"
Foreground="#cdd6f4" Margin="0,0,0,8" />
<TextBlock Text="Stack" FontSize="11" Foreground="#6c7086" /> <!-- Tab 1: Resource preview -->
<TextBlock Text="{Binding PreviewStackName}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#89b4fa" Margin="0,0,0,6" /> <TabItem Header="Resource Preview">
<Border Background="#1e1e2e"
CornerRadius="8"
Padding="16,14">
<StackPanel Spacing="6">
<TextBlock Text="Resource Preview" FontSize="14" FontWeight="SemiBold"
Foreground="#cdd6f4" Margin="0,0,0,8" />
<TextBlock Text="Services" FontSize="11" Foreground="#6c7086" /> <TextBlock Text="Stack" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewServiceWeb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" /> <TextBlock Text="{Binding PreviewStackName}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#89b4fa" Margin="0,0,0,6" />
<TextBlock Text="{Binding PreviewServiceCache}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
<TextBlock Text="{Binding PreviewServiceChart}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
<TextBlock Text="{Binding PreviewServiceNewt}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" Margin="0,0,0,6" />
<TextBlock Text="Network" FontSize="11" Foreground="#6c7086" /> <TextBlock Text="Services" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewNetwork}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#94e2d5" Margin="0,0,0,6" /> <TextBlock Text="{Binding PreviewServiceWeb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
<TextBlock Text="{Binding PreviewServiceCache}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
<TextBlock Text="{Binding PreviewServiceChart}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
<TextBlock Text="{Binding PreviewServiceNewt}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" Margin="0,0,0,6" />
<TextBlock Text="CIFS Volumes" FontSize="11" Foreground="#6c7086" /> <TextBlock Text="Network" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewVolCustom}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" /> <TextBlock Text="{Binding PreviewNetwork}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#94e2d5" Margin="0,0,0,6" />
<TextBlock Text="{Binding PreviewVolBackup}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolLibrary}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolUserscripts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolCaCerts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" Margin="0,0,0,6" />
<TextBlock Text="Docker Secret" FontSize="11" Foreground="#6c7086" /> <TextBlock Text="NFS Volumes" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewSecret}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" Margin="0,0,0,6" /> <TextBlock Text="{Binding PreviewVolCustom}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolBackup}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolLibrary}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolUserscripts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolCaCerts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" Margin="0,0,0,6" />
<TextBlock Text="MySQL Database" FontSize="11" Foreground="#6c7086" /> <TextBlock Text="Docker Secrets" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewMySqlDb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#cba6f7" /> <TextBlock Text="{Binding PreviewSecret}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" />
<TextBlock Text="{Binding PreviewMySqlUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#cba6f7" Margin="0,0,0,6" /> <TextBlock Text="{Binding PreviewSecretUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" />
<TextBlock Text="{Binding PreviewSecretHost}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" />
<TextBlock Text="{Binding PreviewSecretPort}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" Margin="0,0,0,6" />
<TextBlock Text="CMS URL" FontSize="11" Foreground="#6c7086" /> <TextBlock Text="MySQL Database" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewCmsUrl}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#89dceb" /> <TextBlock Text="{Binding PreviewMySqlDb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#cba6f7" />
</StackPanel> <TextBlock Text="{Binding PreviewMySqlUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#cba6f7" Margin="0,0,0,6" />
</Border>
<TextBlock Text="CMS URL" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewCmsUrl}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#89dceb" />
</StackPanel>
</Border>
</TabItem>
<!-- Tab 2: Rendered compose YML -->
<TabItem Header="Compose YML">
<Border Background="#1e1e2e"
CornerRadius="8"
Padding="16,14">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Load button -->
<Button Grid.Row="0"
Content="↻ Load / Refresh YML"
Command="{Binding LoadYmlPreviewCommand}"
IsEnabled="{Binding !IsLoadingYml}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="10,6"
Margin="0,0,0,8" />
<!-- YML text box -->
<TextBox Grid.Row="1"
Text="{Binding PreviewYml}"
IsReadOnly="True"
AcceptsReturn="True"
FontFamily="Cascadia Mono, Consolas, monospace"
FontSize="11"
MinHeight="320"
Watermark="Click 'Load / Refresh YML' to preview the rendered compose file…"
TextWrapping="NoWrap" />
<!-- Copy button -->
<Button Grid.Row="2"
Content="⎘ Copy to Clipboard"
Command="{Binding CopyYmlCommand}"
IsEnabled="{Binding HasPreviewYml}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="10,6"
Margin="0,8,0,0" />
</Grid>
</Border>
</TabItem>
</TabControl>
</Grid> </Grid>
</ScrollViewer> </ScrollViewer>

View File

@@ -18,6 +18,37 @@
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0" <TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
FontSize="12" Foreground="#a6adc8" /> FontSize="12" Foreground="#a6adc8" />
<!-- Remote Nodes panel -->
<Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="8"
Background="#1e1e2e" CornerRadius="8"
MinHeight="120" MaxHeight="300">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
<TextBlock Text="Cluster Nodes" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
<Button Content="List Nodes" Command="{Binding ListNodesCommand}" />
<TextBlock Text="{Binding NodesStatusMessage}" FontSize="12"
Foreground="#a6adc8" VerticalAlignment="Center" Margin="8,0,0,0" />
</StackPanel>
<DataGrid ItemsSource="{Binding RemoteNodes}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True"
HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridTextColumn Header="Hostname" Binding="{Binding Hostname}" Width="160" />
<DataGridTextColumn Header="IP Address" Binding="{Binding IpAddress}" Width="130" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
<DataGridTextColumn Header="Availability" Binding="{Binding Availability}" Width="100" />
<DataGridTextColumn Header="Manager Status" Binding="{Binding ManagerStatus}" Width="120" />
<DataGridTextColumn Header="Engine" Binding="{Binding EngineVersion}" Width="100" />
<DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="200" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
<!-- Edit panel (shown when editing) --> <!-- Edit panel (shown when editing) -->
<Border DockPanel.Dock="Right" Width="350" IsVisible="{Binding IsEditing}" <Border DockPanel.Dock="Right" Width="350" IsVisible="{Binding IsEditing}"
Background="#1e1e2e" CornerRadius="8" Padding="16" Margin="12,0,0,0"> Background="#1e1e2e" CornerRadius="8" Padding="16" Margin="12,0,0,0">

View File

@@ -7,21 +7,13 @@
<DockPanel> <DockPanel>
<!-- Toolbar --> <!-- Toolbar -->
<StackPanel DockPanel.Dock="Top" Spacing="8" Margin="0,0,0,12"> <StackPanel DockPanel.Dock="Top" Spacing="8" Margin="0,0,0,12">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<ComboBox ItemsSource="{Binding AvailableHosts}" <Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
SelectedItem="{Binding SelectedSshHost}"
PlaceholderText="Select SSH Host..."
Width="250">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="List Remote Stacks" Command="{Binding RefreshRemoteStacksCommand}" />
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" /> <Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
<Button Content="Delete" Command="{Binding DeleteInstanceCommand}" /> <Button Content="Delete" Command="{Binding DeleteInstanceCommand}" />
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" /> <Button Content="Rotate DB Password" Command="{Binding RotateMySqlPasswordCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Generate a new MySQL password, update the Docker secret, and redeploy the stack." />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
@@ -57,25 +49,7 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Remote stacks panel --> <!-- Remote stacks panel removed: all live stacks are shown in the main list below -->
<Border DockPanel.Dock="Bottom" MaxHeight="200"
IsVisible="{Binding RemoteStacks.Count}"
Background="#1e1e2e" CornerRadius="8" Padding="12" Margin="0,8,0,0">
<StackPanel Spacing="4">
<TextBlock Text="Remote Stacks" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding RemoteStacks}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock>
<Run Text="{Binding Name}" FontWeight="SemiBold" />
<Run Text="{Binding ServiceCount, StringFormat=' ({0} services)'}"
Foreground="#a6adc8" />
</TextBlock>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Instance list --> <!-- Instance list -->
<DataGrid ItemsSource="{Binding Instances}" <DataGrid ItemsSource="{Binding Instances}"
@@ -85,13 +59,10 @@
GridLinesVisibility="Horizontal" GridLinesVisibility="Horizontal"
CanUserResizeColumns="True"> CanUserResizeColumns="True">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="120" /> <DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="150" />
<DataGridTextColumn Header="Customer" Binding="{Binding CustomerName}" Width="120" /> <DataGridTextColumn Header="Abbrev" Binding="{Binding CustomerAbbrev}" Width="70" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" /> <DataGridTextColumn Header="Services" Binding="{Binding ServiceCount}" Width="70" />
<DataGridTextColumn Header="Server" Binding="{Binding CmsServerName}" Width="150" /> <DataGridTextColumn Header="Host" Binding="{Binding HostLabel}" Width="150" />
<DataGridTextColumn Header="Port" Binding="{Binding HostHttpPort}" Width="60" />
<DataGridTextColumn Header="Host" Binding="{Binding SshHost.Label}" Width="120" />
<DataGridTextColumn Header="Created" Binding="{Binding CreatedAt, StringFormat='{}{0:g}'}" Width="140" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</DockPanel> </DockPanel>

View File

@@ -20,7 +20,7 @@
<DataGridTextColumn Header="Time" Binding="{Binding Timestamp, StringFormat='{}{0:g}'}" Width="150" /> <DataGridTextColumn Header="Time" Binding="{Binding Timestamp, StringFormat='{}{0:g}'}" Width="150" />
<DataGridTextColumn Header="Operation" Binding="{Binding Operation}" Width="100" /> <DataGridTextColumn Header="Operation" Binding="{Binding Operation}" Width="100" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" /> <DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
<DataGridTextColumn Header="Instance" Binding="{Binding Instance.StackName}" Width="120" /> <DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="120" />
<DataGridTextColumn Header="Message" Binding="{Binding Message}" Width="*" /> <DataGridTextColumn Header="Message" Binding="{Binding Message}" Width="*" />
<DataGridTextColumn Header="Duration" Binding="{Binding DurationMs, StringFormat='{}{0}ms'}" Width="80" /> <DataGridTextColumn Header="Duration" Binding="{Binding DurationMs, StringFormat='{}{0}ms'}" Width="80" />
</DataGrid.Columns> </DataGrid.Columns>

View File

@@ -116,31 +116,25 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- ═══ CIFS Volumes ═══ --> <!-- ═══ NFS Volumes ═══ -->
<Border Background="#1e1e2e" CornerRadius="8" Padding="16"> <Border Background="#1e1e2e" CornerRadius="8" Padding="16">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<TextBlock Text="CIFS Volumes" FontSize="16" FontWeight="SemiBold" <TextBlock Text="NFS Volumes" FontSize="16" FontWeight="SemiBold"
Foreground="#cba6f7" Margin="0,0,0,4" /> Foreground="#cba6f7" Margin="0,0,0,4" />
<TextBlock Text="Network share settings for Docker volumes. Volumes will be mounted via CIFS." <TextBlock Text="Network share settings for Docker volumes. Volumes will be mounted via NFS."
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" /> FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
<TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" /> <TextBlock Text="NFS Server (hostname/IP)" FontSize="12" />
<TextBox Text="{Binding CifsServer}" Watermark="nas.local" /> <TextBox Text="{Binding NfsServer}" Watermark="nas.local" />
<TextBlock Text="Share Name" FontSize="12" /> <TextBlock Text="Export Path" FontSize="12" />
<TextBox Text="{Binding CifsShareName}" Watermark="u548897-sub1" /> <TextBox Text="{Binding NfsExport}" Watermark="/srv/nfs" />
<TextBlock Text="Share Folder (optional)" FontSize="12" /> <TextBlock Text="Export Folder (optional)" FontSize="12" />
<TextBox Text="{Binding CifsShareFolder}" Watermark="ots_cms (leave empty for share root)" /> <TextBox Text="{Binding NfsExportFolder}" Watermark="ots_cms (leave empty for export root)" />
<TextBlock Text="Username" FontSize="12" />
<TextBox Text="{Binding CifsUsername}" Watermark="smbuser" />
<TextBlock Text="Password" FontSize="12" />
<TextBox Text="{Binding CifsPassword}" PasswordChar="●" />
<TextBlock Text="Extra Mount Options" FontSize="12" /> <TextBlock Text="Extra Mount Options" FontSize="12" />
<TextBox Text="{Binding CifsOptions}" Watermark="file_mode=0777,dir_mode=0777" /> <TextBox Text="{Binding NfsOptions}" Watermark="Additional options after nfsvers=4,proto=tcp" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -181,7 +175,7 @@
<TextBox Text="{Binding DefaultMySqlDbTemplate}" Watermark="{}{abbrev}_cms_db" /> <TextBox Text="{Binding DefaultMySqlDbTemplate}" Watermark="{}{abbrev}_cms_db" />
<TextBlock Text="MySQL User Template" FontSize="12" /> <TextBlock Text="MySQL User Template" FontSize="12" />
<TextBox Text="{Binding DefaultMySqlUserTemplate}" Watermark="{}{abbrev}_cms" /> <TextBox Text="{Binding DefaultMySqlUserTemplate}" Watermark="{}{abbrev}_cms_user" />
<TextBlock Text="PHP Settings" FontSize="13" FontWeight="SemiBold" Margin="0,12,0,4" /> <TextBlock Text="PHP Settings" FontSize="13" FontWeight="SemiBold" Margin="0,12,0,4" />

View File

@@ -39,7 +39,7 @@
"ThemeHostPath": "/cms/ots-theme", "ThemeHostPath": "/cms/ots-theme",
"LibraryShareSubPath": "{abbrev}-cms-library", "LibraryShareSubPath": "{abbrev}-cms-library",
"MySqlDatabaseTemplate": "{abbrev}_cms_db", "MySqlDatabaseTemplate": "{abbrev}_cms_db",
"MySqlUserTemplate": "{abbrev}_cms", "MySqlUserTemplate": "{abbrev}_cms_user",
"BaseHostHttpPort": 8080 "BaseHostHttpPort": 8080
} }
} }

BIN
otssigns-desktop.db-shm Normal file

Binary file not shown.

BIN
otssigns-desktop.db-wal Normal file

Binary file not shown.

View File

@@ -27,6 +27,9 @@ services:
CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}" CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}"
secrets: secrets:
- {{ABBREV}}-cms-db-password - {{ABBREV}}-cms-db-password
- {{ABBREV}}-cms-db-user
- global_mysql_host
- global_mysql_port
volumes: volumes:
- {{ABBREV}}-cms-custom:/var/www/cms/custom - {{ABBREV}}-cms-custom:/var/www/cms/custom
- {{ABBREV}}-cms-backup:/var/www/backup - {{ABBREV}}-cms-backup:/var/www/backup
@@ -92,34 +95,40 @@ volumes:
{{ABBREV}}-cms-custom: {{ABBREV}}-cms-custom:
driver: local driver: local
driver_opts: driver_opts:
type: cifs type: nfs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-custom device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-custom"
o: {{CIFS_OPTS}} o: "{{NFS_OPTS}}"
{{ABBREV}}-cms-backup: {{ABBREV}}-cms-backup:
driver: local driver: local
driver_opts: driver_opts:
type: cifs type: nfs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-backup device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-backup"
o: {{CIFS_OPTS}} o: "{{NFS_OPTS}}"
{{ABBREV}}-cms-library: {{ABBREV}}-cms-library:
driver: local driver: local
driver_opts: driver_opts:
type: cifs type: nfs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-library device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-library"
o: {{CIFS_OPTS}} o: "{{NFS_OPTS}}"
{{ABBREV}}-cms-userscripts: {{ABBREV}}-cms-userscripts:
driver: local driver: local
driver_opts: driver_opts:
type: cifs type: nfs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-userscripts device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-userscripts"
o: {{CIFS_OPTS}} o: "{{NFS_OPTS}}"
{{ABBREV}}-cms-ca-certs: {{ABBREV}}-cms-ca-certs:
driver: local driver: local
driver_opts: driver_opts:
type: cifs type: nfs
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-ca-certs device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-ca-certs"
o: {{CIFS_OPTS}} o: "{{NFS_OPTS}}"
secrets: secrets:
{{ABBREV}}-cms-db-password: {{ABBREV}}-cms-db-password:
external: true external: true
{{ABBREV}}-cms-db-user:
external: true
global_mysql_host:
external: true
global_mysql_port:
external: true