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
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
Submodule .template-cache/2dc03e2b2b45fef3 updated: 07ab87bc65...292fbb4bfe
@@ -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)
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
339
OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.Designer.cs
generated
Normal file
339
OTSSignsOrchestrator.Core/Migrations/20260219005507_ReplaceCifsWithNfs.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
347
OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.Designer.cs
generated
Normal file
347
OTSSignsOrchestrator.Core/Migrations/20260219020727_AddNewtCredentials.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.Designer.cs
generated
Normal file
153
OTSSignsOrchestrator.Core/Migrations/20260219121529_RemoveCmsInstancesAndSecretMetadata.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
16
OTSSignsOrchestrator.Core/Models/DTOs/NodeInfo.cs
Normal file
16
OTSSignsOrchestrator.Core/Models/DTOs/NodeInfo.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
|
||||||
}
|
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=<server>,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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
25
OTSSignsOrchestrator.Desktop/Models/LiveStackItem.cs
Normal file
25
OTSSignsOrchestrator.Desktop/Models/LiveStackItem.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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:<auto> → <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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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
BIN
otssigns-desktop.db-shm
Normal file
Binary file not shown.
BIN
otssigns-desktop.db-wal
Normal file
BIN
otssigns-desktop.db-wal
Normal file
Binary file not shown.
39
template.yml
39
template.yml
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user