Remove appsettings.json, rename CifsShareBasePath to CifsShareName, add CifsShareFolder to CmsInstances, and create a template.yml for Docker configuration.
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
1
.template-cache/2dc03e2b2b45fef3
Submodule
1
.template-cache/2dc03e2b2b45fef3
Submodule
Submodule .template-cache/2dc03e2b2b45fef3 added at 07ab87bc65
343
OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.Designer.cs
generated
Normal file
343
OTSSignsOrchestrator.Core/Migrations/20260218180240_RenameShareBasePathToShareName.Designer.cs
generated
Normal file
@@ -0,0 +1,343 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
[DbContext(typeof(XiboContext))]
|
||||
[Migration("20260218180240_RenameShareBasePathToShareName")]
|
||||
partial class RenameShareBasePathToShareName
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSensitive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.HasIndex("Category");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsExtraOptions")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsPassword")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsServer")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsShareName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsUsername")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CmsServerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Constraints")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerAbbrev")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("HostHttpPort")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LibraryHostPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SmtpServer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SmtpUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SshHostId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TemplateCacheKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("TemplateLastFetch")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TemplateRepoPat")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TemplateRepoUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThemeHostPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("XiboApiTestStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("XiboApiTestedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("XiboPassword")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("XiboUsername")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomerName");
|
||||
|
||||
b.HasIndex("SshHostId");
|
||||
|
||||
b.HasIndex("StackName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CmsInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("DurationMs")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("InstanceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstanceId");
|
||||
|
||||
b.HasIndex("Operation");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.ToTable("OperationLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsGlobal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("LastRotatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SecretMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("KeyPassphrase")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool?>("LastTestSuccess")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("LastTestedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PrivateKeyPath")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("UseKeyAuth")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Label")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SshHosts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("SshHostId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SshHost");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
|
||||
.WithMany("OperationLogs")
|
||||
.HasForeignKey("InstanceId");
|
||||
|
||||
b.Navigation("Instance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.Navigation("OperationLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RenameShareBasePathToShareName : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "CifsShareBasePath",
|
||||
table: "CmsInstances",
|
||||
newName: "CifsShareName");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "CifsShareName",
|
||||
table: "CmsInstances",
|
||||
newName: "CifsShareBasePath");
|
||||
}
|
||||
}
|
||||
}
|
||||
347
OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.Designer.cs
generated
Normal file
347
OTSSignsOrchestrator.Core/Migrations/20260218202617_AddCifsShareFolder.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("20260218202617_AddCifsShareFolder")]
|
||||
partial class AddCifsShareFolder
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSensitive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.HasIndex("Category");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsExtraOptions")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsPassword")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsServer")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsShareFolder")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsShareName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsUsername")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CmsServerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Constraints")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerAbbrev")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("HostHttpPort")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LibraryHostPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SmtpServer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SmtpUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SshHostId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TemplateCacheKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("TemplateLastFetch")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TemplateRepoPat")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TemplateRepoUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThemeHostPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("XiboApiTestStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("XiboApiTestedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("XiboPassword")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("XiboUsername")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomerName");
|
||||
|
||||
b.HasIndex("SshHostId");
|
||||
|
||||
b.HasIndex("StackName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CmsInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("DurationMs")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("InstanceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstanceId");
|
||||
|
||||
b.HasIndex("Operation");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.ToTable("OperationLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsGlobal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("LastRotatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SecretMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("KeyPassphrase")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool?>("LastTestSuccess")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("LastTestedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PrivateKeyPath")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("UseKeyAuth")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Label")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SshHosts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("SshHostId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SshHost");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
|
||||
.WithMany("OperationLogs")
|
||||
.HasForeignKey("InstanceId");
|
||||
|
||||
b.Navigation("Instance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.Navigation("OperationLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCifsShareFolder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CifsShareFolder",
|
||||
table: "CmsInstances",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CifsShareFolder",
|
||||
table: "CmsInstances");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,11 @@ namespace OTSSignsOrchestrator.Core.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsShareBasePath")
|
||||
b.Property<string>("CifsShareFolder")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CifsShareName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ public class CreateInstanceDto
|
||||
public string? CifsServer { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsShareBasePath { get; set; }
|
||||
public string? CifsShareName { get; set; }
|
||||
|
||||
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root.</summary>
|
||||
[MaxLength(500)]
|
||||
public string? CifsShareFolder { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? CifsUsername { get; set; }
|
||||
|
||||
@@ -3,6 +3,5 @@ namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
public class TemplateConfig
|
||||
{
|
||||
public string Yaml { get; set; } = string.Empty;
|
||||
public List<string> EnvLines { get; set; } = new();
|
||||
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ public class UpdateInstanceDto
|
||||
public string? CifsServer { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsShareBasePath { get; set; }
|
||||
public string? CifsShareName { get; set; }
|
||||
|
||||
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root.</summary>
|
||||
[MaxLength(500)]
|
||||
public string? CifsShareFolder { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? CifsUsername { get; set; }
|
||||
|
||||
@@ -95,7 +95,11 @@ public class CmsInstance
|
||||
public string? CifsServer { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsShareBasePath { get; set; }
|
||||
public string? CifsShareName { get; set; }
|
||||
|
||||
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Omit to use the share root.</summary>
|
||||
[MaxLength(500)]
|
||||
public string? CifsShareFolder { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? CifsUsername { get; set; }
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.2" />
|
||||
<PackageReference Include="MySqlConnector" Version="2.5.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Docker Compose v3.9 YAML for a Xibo CMS stack.
|
||||
/// Combined format: no separate config.env, no MySQL container (external DB),
|
||||
/// CIFS volumes, Newt tunnel service, and inline environment variables.
|
||||
/// Renders a Docker Compose file by loading a template from the git repo and substituting
|
||||
/// all {{PLACEHOLDER}} tokens with values from RenderContext.
|
||||
/// The template file expected in the repo is <c>template.yml</c>.
|
||||
/// Call <see cref="GetTemplateYaml"/> to obtain the canonical template to commit to your repo.
|
||||
/// </summary>
|
||||
public class ComposeRenderService
|
||||
{
|
||||
@@ -22,278 +17,220 @@ public class ComposeRenderService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Render(RenderContext ctx)
|
||||
/// <summary>
|
||||
/// Substitutes all {{PLACEHOLDER}} tokens in <paramref name="templateYaml"/> and returns
|
||||
/// the final compose YAML ready for deployment.
|
||||
/// </summary>
|
||||
public string Render(string templateYaml, RenderContext ctx)
|
||||
{
|
||||
_logger.LogInformation("Rendering Compose for stack: {StackName}", ctx.StackName);
|
||||
_logger.LogInformation("Rendering Compose for stack {StackName} from template", ctx.StackName);
|
||||
|
||||
var root = new YamlMappingNode();
|
||||
if (string.IsNullOrWhiteSpace(templateYaml))
|
||||
throw new ArgumentException("Template YAML is empty. Ensure template.yml exists in the configured git repository.");
|
||||
|
||||
// Version
|
||||
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
|
||||
var cifsOpts = BuildCifsOpts(ctx);
|
||||
|
||||
// Comment — customer name (added as a YAML comment isn't natively supported,
|
||||
// so we prepend it manually after serialization)
|
||||
BuildServices(root, ctx);
|
||||
BuildNetworks(root, ctx);
|
||||
BuildVolumes(root, ctx);
|
||||
BuildSecrets(root, ctx);
|
||||
|
||||
var doc = new YamlDocument(root);
|
||||
var stream = new YamlStream(doc);
|
||||
|
||||
using var writer = new StringWriter();
|
||||
stream.Save(writer, assignAnchors: false);
|
||||
var output = writer.ToString()
|
||||
.Replace("...\n", "").Replace("...", "");
|
||||
|
||||
// Prepend customer name comment
|
||||
output = $"# Customer: {ctx.CustomerName}\n{output}";
|
||||
|
||||
_logger.LogDebug("Compose rendered for {StackName}: {ServiceCount} services",
|
||||
ctx.StackName, 4);
|
||||
|
||||
return output;
|
||||
return templateYaml
|
||||
.Replace("{{ABBREV}}", ctx.CustomerAbbrev)
|
||||
.Replace("{{CUSTOMER_NAME}}", ctx.CustomerName)
|
||||
.Replace("{{STACK_NAME}}", ctx.StackName)
|
||||
.Replace("{{CMS_SERVER_NAME}}", ctx.CmsServerName)
|
||||
.Replace("{{HOST_HTTP_PORT}}", ctx.HostHttpPort.ToString())
|
||||
.Replace("{{CMS_IMAGE}}", ctx.CmsImage)
|
||||
.Replace("{{MEMCACHED_IMAGE}}", ctx.MemcachedImage)
|
||||
.Replace("{{QUICKCHART_IMAGE}}", ctx.QuickChartImage)
|
||||
.Replace("{{NEWT_IMAGE}}", ctx.NewtImage)
|
||||
.Replace("{{THEME_HOST_PATH}}", ctx.ThemeHostPath)
|
||||
.Replace("{{MYSQL_HOST}}", ctx.MySqlHost)
|
||||
.Replace("{{MYSQL_PORT}}", ctx.MySqlPort)
|
||||
.Replace("{{MYSQL_DATABASE}}", ctx.MySqlDatabase)
|
||||
.Replace("{{MYSQL_USER}}", ctx.MySqlUser)
|
||||
.Replace("{{SMTP_SERVER}}", ctx.SmtpServer)
|
||||
.Replace("{{SMTP_USERNAME}}", ctx.SmtpUsername)
|
||||
.Replace("{{SMTP_PASSWORD}}", ctx.SmtpPassword)
|
||||
.Replace("{{SMTP_USE_TLS}}", ctx.SmtpUseTls)
|
||||
.Replace("{{SMTP_USE_STARTTLS}}", ctx.SmtpUseStartTls)
|
||||
.Replace("{{SMTP_REWRITE_DOMAIN}}", ctx.SmtpRewriteDomain)
|
||||
.Replace("{{SMTP_HOSTNAME}}", ctx.SmtpHostname)
|
||||
.Replace("{{SMTP_FROM_LINE_OVERRIDE}}", ctx.SmtpFromLineOverride)
|
||||
.Replace("{{PHP_POST_MAX_SIZE}}", ctx.PhpPostMaxSize)
|
||||
.Replace("{{PHP_UPLOAD_MAX_FILESIZE}}", ctx.PhpUploadMaxFilesize)
|
||||
.Replace("{{PHP_MAX_EXECUTION_TIME}}", ctx.PhpMaxExecutionTime)
|
||||
.Replace("{{PANGOLIN_ENDPOINT}}", ctx.PangolinEndpoint)
|
||||
.Replace("{{NEWT_ID}}", ctx.NewtId ?? "CONFIGURE_ME")
|
||||
.Replace("{{NEWT_SECRET}}", ctx.NewtSecret ?? "CONFIGURE_ME")
|
||||
.Replace("{{CIFS_SERVER}}", (ctx.CifsServer ?? string.Empty).TrimEnd('/'))
|
||||
.Replace("{{CIFS_SHARE_NAME}}", BuildSharePath(ctx.CifsShareName, ctx.CifsShareFolder))
|
||||
// Legacy token — was a path component (e.g. "/sharename"), so templates concatenate
|
||||
// it directly after the server: //{{CIFS_SERVER}}{{CIFS_SHARE_BASE_PATH}}/...
|
||||
// We must keep the leading "/" to produce a valid device path.
|
||||
.Replace("{{CIFS_SHARE_BASE_PATH}}", "/" + BuildSharePath(ctx.CifsShareName, ctx.CifsShareFolder))
|
||||
.Replace("{{CIFS_USERNAME}}", ctx.CifsUsername ?? string.Empty)
|
||||
.Replace("{{CIFS_PASSWORD}}", ctx.CifsPassword ?? string.Empty)
|
||||
.Replace("{{CIFS_OPTS}}", cifsOpts);
|
||||
}
|
||||
|
||||
// ── Services ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildServices(YamlMappingNode root, RenderContext ctx)
|
||||
private static string BuildCifsOpts(RenderContext ctx)
|
||||
{
|
||||
var services = new YamlMappingNode();
|
||||
root.Children[new YamlScalarNode("services")] = services;
|
||||
if (string.IsNullOrWhiteSpace(ctx.CifsServer))
|
||||
return string.Empty;
|
||||
|
||||
BuildWebService(services, ctx);
|
||||
BuildMemcachedService(services, ctx);
|
||||
BuildQuickChartService(services, ctx);
|
||||
|
||||
if (ctx.IncludeNewt)
|
||||
BuildNewtService(services, ctx);
|
||||
// vers=3.0 is required by most modern SMB servers (e.g. Hetzner Storage Box).
|
||||
// Without it, mount.cifs may negotiate SMBv1/2.1 which gets rejected as "permission denied".
|
||||
var opts = $"addr={ctx.CifsServer},username={ctx.CifsUsername},password={ctx.CifsPassword},vers=3.0";
|
||||
if (!string.IsNullOrWhiteSpace(ctx.CifsExtraOptions))
|
||||
opts += $",{ctx.CifsExtraOptions}";
|
||||
return opts;
|
||||
}
|
||||
|
||||
private void BuildWebService(YamlMappingNode services, RenderContext ctx)
|
||||
/// <summary>
|
||||
/// Combines share name and optional subfolder into a single path segment.
|
||||
/// e.g. ("u548897-sub1", "ots_cms") → "u548897-sub1/ots_cms"
|
||||
/// ("u548897-sub1", null) → "u548897-sub1"
|
||||
/// </summary>
|
||||
private static string BuildSharePath(string? shareName, string? shareFolder)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-web")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.CmsImage);
|
||||
|
||||
// Environment — all config.env values merged inline
|
||||
var env = new YamlMappingNode
|
||||
{
|
||||
{ "CMS_USE_MEMCACHED", "true" },
|
||||
{ "MEMCACHED_HOST", "memcached" },
|
||||
{ "MYSQL_HOST", ctx.MySqlHost },
|
||||
{ "MYSQL_PORT", ctx.MySqlPort },
|
||||
{ "MYSQL_DATABASE", ctx.MySqlDatabase },
|
||||
{ "MYSQL_USER", ctx.MySqlUser },
|
||||
{ "MYSQL_PASSWORD_FILE", $"/run/secrets/{ctx.CustomerAbbrev}-cms-db-password" },
|
||||
{ "CMS_SMTP_SERVER", ctx.SmtpServer },
|
||||
{ "CMS_SMTP_USERNAME", ctx.SmtpUsername },
|
||||
{ "CMS_SMTP_PASSWORD", ctx.SmtpPassword },
|
||||
{ "CMS_SMTP_USE_TLS", ctx.SmtpUseTls },
|
||||
{ "CMS_SMTP_USE_STARTTLS", ctx.SmtpUseStartTls },
|
||||
{ "CMS_SMTP_REWRITE_DOMAIN", ctx.SmtpRewriteDomain },
|
||||
{ "CMS_SMTP_HOSTNAME", ctx.SmtpHostname },
|
||||
{ "CMS_SMTP_FROM_LINE_OVERRIDE", ctx.SmtpFromLineOverride },
|
||||
{ "CMS_SERVER_NAME", ctx.CmsServerName },
|
||||
{ "CMS_PHP_POST_MAX_SIZE", ctx.PhpPostMaxSize },
|
||||
{ "CMS_PHP_UPLOAD_MAX_FILESIZE", ctx.PhpUploadMaxFilesize },
|
||||
{ "CMS_PHP_MAX_EXECUTION_TIME", ctx.PhpMaxExecutionTime },
|
||||
};
|
||||
svc.Children[new YamlScalarNode("environment")] = env;
|
||||
|
||||
// Secrets
|
||||
var secrets = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-db-password")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("secrets")] = secrets;
|
||||
|
||||
// Volumes
|
||||
var volumes = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-custom:/var/www/cms/custom"),
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-backup:/var/www/backup"),
|
||||
new YamlScalarNode($"{ctx.ThemeHostPath}:/var/www/cms/web/theme/custom"),
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-library:/var/www/cms/library"),
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-userscripts:/var/www/cms/web/userscripts"),
|
||||
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-ca-certs:/var/www/cms/ca-certs")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("volumes")] = volumes;
|
||||
|
||||
// Ports
|
||||
var ports = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{ctx.HostHttpPort}:80")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("ports")] = ports;
|
||||
|
||||
// Networks
|
||||
var webNet = new YamlMappingNode
|
||||
{
|
||||
{ "aliases", new YamlSequenceNode(new YamlScalarNode("web")) }
|
||||
};
|
||||
var networks = new YamlMappingNode();
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = webNet;
|
||||
svc.Children[new YamlScalarNode("networks")] = networks;
|
||||
|
||||
// Deploy
|
||||
var deploy = new YamlMappingNode
|
||||
{
|
||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
|
||||
{ "resources", new YamlMappingNode
|
||||
{ { "limits", new YamlMappingNode { { "memory", "1G" } } } }
|
||||
}
|
||||
};
|
||||
svc.Children[new YamlScalarNode("deploy")] = deploy;
|
||||
var name = (shareName ?? string.Empty).Trim('/');
|
||||
var folder = (shareFolder ?? string.Empty).Trim('/');
|
||||
return string.IsNullOrEmpty(folder) ? name : $"{name}/{folder}";
|
||||
}
|
||||
|
||||
private void BuildMemcachedService(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-memcached")] = svc;
|
||||
/// <summary>
|
||||
/// Returns the canonical <c>template.yml</c> content with all placeholders.
|
||||
/// Commit this file to the root of your template git repository.
|
||||
/// </summary>
|
||||
public static string GetTemplateYaml() => TemplateYaml;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.MemcachedImage);
|
||||
// ── Canonical template ──────────────────────────────────────────────────
|
||||
|
||||
var command = new YamlSequenceNode(
|
||||
new YamlScalarNode("memcached"),
|
||||
new YamlScalarNode("-m"),
|
||||
new YamlScalarNode("15")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("command")] = command;
|
||||
public const string TemplateYaml =
|
||||
"""
|
||||
# Customer: {{CUSTOMER_NAME}}
|
||||
version: "3.9"
|
||||
|
||||
var mcNet = new YamlMappingNode
|
||||
{
|
||||
{ "aliases", new YamlSequenceNode(new YamlScalarNode("memcached")) }
|
||||
};
|
||||
var networks = new YamlMappingNode();
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = mcNet;
|
||||
svc.Children[new YamlScalarNode("networks")] = networks;
|
||||
services:
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
|
||||
{
|
||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
|
||||
{ "resources", new YamlMappingNode
|
||||
{ { "limits", new YamlMappingNode { { "memory", "100M" } } } }
|
||||
}
|
||||
};
|
||||
}
|
||||
{{ABBREV}}-web:
|
||||
image: {{CMS_IMAGE}}
|
||||
environment:
|
||||
CMS_USE_MEMCACHED: "true"
|
||||
MEMCACHED_HOST: memcached
|
||||
MYSQL_HOST: {{MYSQL_HOST}}
|
||||
MYSQL_PORT: "{{MYSQL_PORT}}"
|
||||
MYSQL_DATABASE: {{MYSQL_DATABASE}}
|
||||
MYSQL_USER: {{MYSQL_USER}}
|
||||
MYSQL_PASSWORD_FILE: /run/secrets/{{ABBREV}}-cms-db-password
|
||||
CMS_SERVER_NAME: {{CMS_SERVER_NAME}}
|
||||
CMS_SMTP_SERVER: {{SMTP_SERVER}}
|
||||
CMS_SMTP_USERNAME: {{SMTP_USERNAME}}
|
||||
CMS_SMTP_PASSWORD: {{SMTP_PASSWORD}}
|
||||
CMS_SMTP_USE_TLS: {{SMTP_USE_TLS}}
|
||||
CMS_SMTP_USE_STARTTLS: {{SMTP_USE_STARTTLS}}
|
||||
CMS_SMTP_REWRITE_DOMAIN: {{SMTP_REWRITE_DOMAIN}}
|
||||
CMS_SMTP_HOSTNAME: {{SMTP_HOSTNAME}}
|
||||
CMS_SMTP_FROM_LINE_OVERRIDE: {{SMTP_FROM_LINE_OVERRIDE}}
|
||||
CMS_PHP_POST_MAX_SIZE: {{PHP_POST_MAX_SIZE}}
|
||||
CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}}
|
||||
CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}"
|
||||
secrets:
|
||||
- {{ABBREV}}-cms-db-password
|
||||
volumes:
|
||||
- {{ABBREV}}-cms-custom:/var/www/cms/custom
|
||||
- {{ABBREV}}-cms-backup:/var/www/backup
|
||||
- {{THEME_HOST_PATH}}:/var/www/cms/web/theme/custom
|
||||
- {{ABBREV}}-cms-library:/var/www/cms/library
|
||||
- {{ABBREV}}-cms-userscripts:/var/www/cms/web/userscripts
|
||||
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
|
||||
ports:
|
||||
- "{{HOST_HTTP_PORT}}:80"
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
aliases:
|
||||
- web
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
|
||||
private void BuildQuickChartService(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-quickchart")] = svc;
|
||||
{{ABBREV}}-memcached:
|
||||
image: {{MEMCACHED_IMAGE}}
|
||||
command: [memcached, -m, "15"]
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
aliases:
|
||||
- memcached
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
resources:
|
||||
limits:
|
||||
memory: 100M
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.QuickChartImage);
|
||||
{{ABBREV}}-quickchart:
|
||||
image: {{QUICKCHART_IMAGE}}
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
aliases:
|
||||
- quickchart
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
|
||||
var qcNet = new YamlMappingNode
|
||||
{
|
||||
{ "aliases", new YamlSequenceNode(new YamlScalarNode("quickchart")) }
|
||||
};
|
||||
var networks = new YamlMappingNode();
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = qcNet;
|
||||
svc.Children[new YamlScalarNode("networks")] = networks;
|
||||
{{ABBREV}}-newt:
|
||||
image: {{NEWT_IMAGE}}
|
||||
environment:
|
||||
PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}}
|
||||
NEWT_ID: {{NEWT_ID}}
|
||||
NEWT_SECRET: {{NEWT_SECRET}}
|
||||
networks:
|
||||
{{ABBREV}}-net: {}
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
|
||||
{
|
||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
|
||||
};
|
||||
}
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
driver: overlay
|
||||
attachable: "false"
|
||||
|
||||
private void BuildNewtService(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-newt")] = svc;
|
||||
volumes:
|
||||
{{ABBREV}}-cms-custom:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-custom
|
||||
o: {{CIFS_OPTS}}
|
||||
{{ABBREV}}-cms-backup:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-backup
|
||||
o: {{CIFS_OPTS}}
|
||||
{{ABBREV}}-cms-library:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-library
|
||||
o: {{CIFS_OPTS}}
|
||||
{{ABBREV}}-cms-userscripts:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-userscripts
|
||||
o: {{CIFS_OPTS}}
|
||||
{{ABBREV}}-cms-ca-certs:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-ca-certs
|
||||
o: {{CIFS_OPTS}}
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.NewtImage);
|
||||
|
||||
var env = new YamlMappingNode
|
||||
{
|
||||
{ "PANGOLIN_ENDPOINT", ctx.PangolinEndpoint },
|
||||
{ "NEWT_ID", ctx.NewtId ?? "CONFIGURE_ME" },
|
||||
{ "NEWT_SECRET", ctx.NewtSecret ?? "CONFIGURE_ME" },
|
||||
};
|
||||
svc.Children[new YamlScalarNode("environment")] = env;
|
||||
|
||||
var networks = new YamlMappingNode();
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = new YamlMappingNode();
|
||||
svc.Children[new YamlScalarNode("networks")] = networks;
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
|
||||
{
|
||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
|
||||
};
|
||||
}
|
||||
|
||||
// ── Networks ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildNetworks(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var netDef = new YamlMappingNode
|
||||
{
|
||||
{ "driver", "overlay" },
|
||||
{ "attachable", "false" }
|
||||
};
|
||||
var networks = new YamlMappingNode();
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = netDef;
|
||||
root.Children[new YamlScalarNode("networks")] = networks;
|
||||
}
|
||||
|
||||
// ── Volumes (CIFS) ──────────────────────────────────────────────────────
|
||||
|
||||
private void BuildVolumes(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var volumes = new YamlMappingNode();
|
||||
root.Children[new YamlScalarNode("volumes")] = volumes;
|
||||
|
||||
var volumeNames = new[]
|
||||
{
|
||||
$"{ctx.CustomerAbbrev}-cms-custom",
|
||||
$"{ctx.CustomerAbbrev}-cms-backup",
|
||||
$"{ctx.CustomerAbbrev}-cms-library",
|
||||
$"{ctx.CustomerAbbrev}-cms-userscripts",
|
||||
$"{ctx.CustomerAbbrev}-cms-ca-certs",
|
||||
};
|
||||
|
||||
foreach (var volName in volumeNames)
|
||||
{
|
||||
if (ctx.UseCifsVolumes && !string.IsNullOrWhiteSpace(ctx.CifsServer))
|
||||
{
|
||||
var device = $"//{ctx.CifsServer}{ctx.CifsShareBasePath}/{volName}";
|
||||
var opts = $"addr={ctx.CifsServer},username={ctx.CifsUsername},password={ctx.CifsPassword}";
|
||||
if (!string.IsNullOrWhiteSpace(ctx.CifsExtraOptions))
|
||||
opts += $",{ctx.CifsExtraOptions}";
|
||||
|
||||
var volDef = new YamlMappingNode
|
||||
{
|
||||
{ "driver", "local" },
|
||||
{ "driver_opts", new YamlMappingNode
|
||||
{
|
||||
{ "type", "cifs" },
|
||||
{ "device", device },
|
||||
{ "o", opts }
|
||||
}
|
||||
}
|
||||
};
|
||||
volumes.Children[new YamlScalarNode(volName)] = volDef;
|
||||
}
|
||||
else
|
||||
{
|
||||
volumes.Children[new YamlScalarNode(volName)] = new YamlMappingNode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Secrets ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildSecrets(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var secrets = new YamlMappingNode();
|
||||
root.Children[new YamlScalarNode("secrets")] = secrets;
|
||||
|
||||
foreach (var secretName in ctx.SecretNames)
|
||||
{
|
||||
secrets.Children[new YamlScalarNode(secretName)] =
|
||||
new YamlMappingNode { { "external", "true" } };
|
||||
}
|
||||
}
|
||||
secrets:
|
||||
{{ABBREV}}-cms-db-password:
|
||||
external: true
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>Context object with all inputs needed to render a Compose file.</summary>
|
||||
@@ -336,26 +273,16 @@ public class RenderContext
|
||||
public string PhpMaxExecutionTime { get; set; } = "600";
|
||||
|
||||
// Pangolin / Newt
|
||||
public bool IncludeNewt { get; set; } = true;
|
||||
public string PangolinEndpoint { get; set; } = "https://app.pangolin.net";
|
||||
public string? NewtId { get; set; }
|
||||
public string? NewtSecret { get; set; }
|
||||
|
||||
// CIFS volume settings
|
||||
public bool UseCifsVolumes { get; set; }
|
||||
public string? CifsServer { get; set; }
|
||||
public string? CifsShareBasePath { get; set; }
|
||||
public string? CifsShareName { get; set; }
|
||||
/// <summary>Optional subfolder within the share (e.g. "ots_cms"). Empty/null = share root.</summary>
|
||||
public string? CifsShareFolder { get; set; }
|
||||
public string? CifsUsername { get; set; }
|
||||
public string? CifsPassword { get; set; }
|
||||
public string? CifsExtraOptions { get; set; }
|
||||
|
||||
// Secrets to declare as external
|
||||
public List<string> SecretNames { get; set; } = new();
|
||||
|
||||
// Legacy — kept for backward compat but no longer used
|
||||
public string TemplateYaml { get; set; } = string.Empty;
|
||||
public Dictionary<string, string> TemplateEnvValues { get; set; } = new();
|
||||
public List<string> TemplateEnvLines { get; set; } = new();
|
||||
public List<string> Constraints { get; set; } = new();
|
||||
public string LibraryHostPath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -47,22 +47,15 @@ public class GitTemplateService
|
||||
});
|
||||
|
||||
var yamlPath = FindFile(cacheDir, "template.yml");
|
||||
var envPath = FindFile(cacheDir, "template.env");
|
||||
|
||||
if (yamlPath == null)
|
||||
throw new FileNotFoundException("template.yml not found in repository root.");
|
||||
if (envPath == null)
|
||||
throw new FileNotFoundException("template.env not found in repository root.");
|
||||
throw new FileNotFoundException("template.yml not found in repository root. Commit the template file produced by ComposeRenderService.GetTemplateYaml() to the repo root.");
|
||||
|
||||
var yaml = await File.ReadAllTextAsync(yamlPath);
|
||||
var envLines = (await File.ReadAllLinesAsync(envPath))
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l) && !l.TrimStart().StartsWith('#'))
|
||||
.ToList();
|
||||
|
||||
return new TemplateConfig
|
||||
{
|
||||
Yaml = yaml,
|
||||
EnvLines = envLines,
|
||||
FetchedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,30 @@ public interface IDockerCliService
|
||||
Task<DeploymentResultDto> RemoveStackAsync(string stackName);
|
||||
Task<List<StackInfo>> ListStacksAsync();
|
||||
Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName);
|
||||
|
||||
/// <summary>Ensures a directory exists on the target host (equivalent to mkdir -p).</summary>
|
||||
Task<bool> EnsureDirectoryAsync(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the required folders exist on an SMB/CIFS share, creating any that are missing.
|
||||
/// If <paramref name="cifsShareFolder"/> is non-empty, creates it first as a subfolder of the share,
|
||||
/// then creates the volume folders inside it.
|
||||
/// Uses smbclient on the remote host to interact with the share without requiring a mount.
|
||||
/// </summary>
|
||||
Task<bool> EnsureSmbFoldersAsync(
|
||||
string cifsServer,
|
||||
string cifsShareName,
|
||||
string cifsUsername,
|
||||
string cifsPassword,
|
||||
IEnumerable<string> folderNames,
|
||||
string? cifsShareFolder = null);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all Docker volumes whose names start with <paramref name="stackName"/>_.
|
||||
/// Volumes currently in use by running containers will be skipped.
|
||||
/// Safe for CIFS volumes since data lives on the remote share, not in the local volume.
|
||||
/// </summary>
|
||||
Task<bool> RemoveStackVolumesAsync(string stackName);
|
||||
}
|
||||
|
||||
public class StackInfo
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MySqlConnector;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
@@ -72,21 +73,33 @@ public class InstanceService
|
||||
{
|
||||
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
|
||||
|
||||
// ── Check uniqueness ────────────────────────────────────────────
|
||||
// ── Check uniqueness — redirect to update if already present ───
|
||||
var existing = await _db.CmsInstances.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
|
||||
if (existing != null)
|
||||
throw new InvalidOperationException($"Stack '{stackName}' already exists.");
|
||||
{
|
||||
_logger.LogInformation("Instance '{StackName}' already exists in DB — applying stack update instead.", stackName);
|
||||
var updateDto = new UpdateInstanceDto
|
||||
{
|
||||
CifsServer = dto.CifsServer,
|
||||
CifsShareName = dto.CifsShareName,
|
||||
CifsShareFolder = dto.CifsShareFolder,
|
||||
CifsUsername = dto.CifsUsername,
|
||||
CifsPassword = dto.CifsPassword,
|
||||
CifsExtraOptions = dto.CifsExtraOptions,
|
||||
};
|
||||
return await UpdateInstanceAsync(existing.Id, updateDto, userId);
|
||||
}
|
||||
|
||||
// ── 1. Clone template repo (optional) ───────────────────────────
|
||||
// ── 1. Clone / refresh template repo ────────────────────────────
|
||||
var repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
|
||||
var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(repoUrl))
|
||||
{
|
||||
_logger.LogInformation("Cloning template repo: {RepoUrl}", repoUrl);
|
||||
await _git.FetchAsync(repoUrl, repoPat);
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
throw new InvalidOperationException("Git template repository URL is not configured. Set it in Settings → Git Repo URL.");
|
||||
|
||||
_logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl);
|
||||
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
|
||||
|
||||
// ── 2. Generate MySQL password → Docker Swarm secret ────────────
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
@@ -116,7 +129,8 @@ public class InstanceService
|
||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
var cifsServer = dto.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
|
||||
var cifsShareBasePath = dto.CifsShareBasePath ?? await _settings.GetAsync(SettingsService.CifsShareBasePath);
|
||||
var cifsShareName = dto.CifsShareName ?? await _settings.GetAsync(SettingsService.CifsShareName);
|
||||
var cifsShareFolder = dto.CifsShareFolder ?? await _settings.GetAsync(SettingsService.CifsShareFolder);
|
||||
var cifsUsername = dto.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
|
||||
var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
|
||||
var cifsOptions = dto.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
@@ -130,7 +144,7 @@ public class InstanceService
|
||||
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
// ── 4. Render compose YAML ──────────────────────────────────────
|
||||
// ── 4. Render compose YAML from template ────────────────────────
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
@@ -158,20 +172,21 @@ public class InstanceService
|
||||
PhpPostMaxSize = phpPostMaxSize,
|
||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||
IncludeNewt = true,
|
||||
PangolinEndpoint = pangolinEndpoint,
|
||||
NewtId = dto.NewtId,
|
||||
NewtSecret = dto.NewtSecret,
|
||||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsShareName = cifsShareName,
|
||||
CifsShareFolder = cifsShareFolder,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
_logger.LogInformation("CIFS render values: server={CifsServer}, share={CifsShareName}, folder={CifsShareFolder}, user={CifsUsername}",
|
||||
cifsServer, cifsShareName, cifsShareFolder, cifsUsername);
|
||||
|
||||
var composeYaml = _compose.Render(templateConfig.Yaml, renderCtx);
|
||||
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
@@ -180,12 +195,35 @@ public class InstanceService
|
||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// ── 5. Deploy stack ─────────────────────────────────────────────
|
||||
// ── 5. Ensure bind-mount directories exist on the remote host ───
|
||||
if (!string.IsNullOrWhiteSpace(themePath))
|
||||
await _docker.EnsureDirectoryAsync(themePath);
|
||||
|
||||
// ── 5b. Ensure SMB share folders exist ───────────────────────────
|
||||
if (!string.IsNullOrWhiteSpace(cifsServer) && !string.IsNullOrWhiteSpace(cifsShareName))
|
||||
{
|
||||
var smbFolders = new[]
|
||||
{
|
||||
$"{abbrev}-cms-custom",
|
||||
$"{abbrev}-cms-backup",
|
||||
$"{abbrev}-cms-library",
|
||||
$"{abbrev}-cms-userscripts",
|
||||
$"{abbrev}-cms-ca-certs",
|
||||
};
|
||||
_logger.LogInformation("Ensuring SMB share folders exist on //{Server}/{Share}", cifsServer, cifsShareName);
|
||||
await _docker.EnsureSmbFoldersAsync(cifsServer, cifsShareName, cifsUsername ?? string.Empty, cifsPassword ?? string.Empty, smbFolders, cifsShareFolder);
|
||||
}
|
||||
|
||||
// ── 6. Remove stale CIFS volumes so Docker recreates them with current settings ─
|
||||
_logger.LogInformation("Removing stale CIFS volumes for stack {StackName} to ensure fresh mount options", stackName);
|
||||
await _docker.RemoveStackVolumesAsync(stackName);
|
||||
|
||||
// ── 7. Deploy stack ─────────────────────────────────────────────
|
||||
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
||||
|
||||
// ── 6. Record instance ──────────────────────────────────────────
|
||||
// ── 8. Record instance ──────────────────────────────────────────
|
||||
var instance = new CmsInstance
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
@@ -202,7 +240,8 @@ public class InstanceService
|
||||
Status = InstanceStatus.Active,
|
||||
SshHostId = dto.SshHostId,
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsShareName = cifsShareName,
|
||||
CifsShareFolder = cifsShareFolder,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
@@ -220,7 +259,7 @@ public class InstanceService
|
||||
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
|
||||
stackName, instance.Id, sw.ElapsedMilliseconds);
|
||||
|
||||
deployResult.ServiceCount = renderCtx.IncludeNewt ? 4 : 3;
|
||||
deployResult.ServiceCount = 4;
|
||||
deployResult.Message = "Instance deployed successfully.";
|
||||
return deployResult;
|
||||
}
|
||||
@@ -238,13 +277,13 @@ public class InstanceService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates MySQL database and user on external MySQL server via SSH.
|
||||
/// Called by the ViewModel before CreateInstanceAsync since it needs SSH access.
|
||||
/// Creates MySQL database and user on the external MySQL server using a direct TCP connection.
|
||||
/// Admin credentials are sourced from SettingsService (MySql.AdminUser / MySql.AdminPassword).
|
||||
/// The new user's password is passed in and never logged.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
|
||||
string abbrev,
|
||||
string mysqlPassword,
|
||||
Func<string, Task<(int ExitCode, string Stdout, string Stderr)>> runSshCommand)
|
||||
string mysqlPassword)
|
||||
{
|
||||
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
@@ -254,29 +293,65 @@ public class InstanceService
|
||||
var dbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var safePwd = mySqlAdminPassword.Replace("'", "'\\''");
|
||||
var safeUserPwd = mysqlPassword.Replace("'", "'\\''");
|
||||
_logger.LogInformation("Creating MySQL database {Db} and user {User} via direct TCP", dbName, userName);
|
||||
|
||||
var sql = $"CREATE DATABASE IF NOT EXISTS \\`{dbName}\\`; "
|
||||
+ $"CREATE USER IF NOT EXISTS '{userName}'@'%' IDENTIFIED BY '{safeUserPwd}'; "
|
||||
+ $"GRANT ALL PRIVILEGES ON \\`{dbName}\\`.* TO '{userName}'@'%'; "
|
||||
+ $"FLUSH PRIVILEGES;";
|
||||
if (!int.TryParse(mySqlPort, out var port))
|
||||
port = 3306;
|
||||
|
||||
var cmd = $"mysql -h {mySqlHost} -P {mySqlPort} -u {mySqlAdminUser} -p'{safePwd}' -e \"{sql}\" 2>&1";
|
||||
|
||||
_logger.LogInformation("Creating MySQL database {Db} and user {User}", dbName, userName);
|
||||
|
||||
var (exitCode, stdout, stderr) = await runSshCommand(cmd);
|
||||
|
||||
if (exitCode == 0)
|
||||
var csb = new MySqlConnectionStringBuilder
|
||||
{
|
||||
_logger.LogInformation("MySQL database and user created: {Db} / {User}", dbName, userName);
|
||||
Server = mySqlHost,
|
||||
Port = (uint)port,
|
||||
UserID = mySqlAdminUser,
|
||||
Password = mySqlAdminPassword,
|
||||
ConnectionTimeout = 15,
|
||||
SslMode = MySqlSslMode.Preferred,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new MySqlConnection(csb.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
// Backtick-escape database name and single-quote-escape username to handle
|
||||
// any special characters in names. The new user password is passed as a
|
||||
// parameter so it is never interpolated into SQL text.
|
||||
var escapedDb = dbName.Replace("`", "``");
|
||||
var escapedUser = userName.Replace("'", "''");
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"CREATE DATABASE IF NOT EXISTS `{escapedDb}`";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"CREATE USER IF NOT EXISTS '{escapedUser}'@'%' IDENTIFIED BY @pwd";
|
||||
cmd.Parameters.AddWithValue("@pwd", mysqlPassword);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"GRANT ALL PRIVILEGES ON `{escapedDb}`.* TO '{escapedUser}'@'%'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "FLUSH PRIVILEGES";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("MySQL database {Db} and user {User} created successfully", dbName, userName);
|
||||
return (true, $"Database '{dbName}' and user '{userName}' created.");
|
||||
}
|
||||
|
||||
var error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
|
||||
_logger.LogError("MySQL setup failed: {Error}", error);
|
||||
return (false, $"MySQL setup failed: {error.Trim()}");
|
||||
catch (MySqlException ex)
|
||||
{
|
||||
_logger.LogError(ex, "MySQL setup failed for database {Db}", dbName);
|
||||
return (false, $"MySQL setup failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null)
|
||||
@@ -299,7 +374,8 @@ public class InstanceService
|
||||
if (dto.XiboUsername != null) instance.XiboUsername = dto.XiboUsername;
|
||||
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
|
||||
if (dto.CifsServer != null) instance.CifsServer = dto.CifsServer;
|
||||
if (dto.CifsShareBasePath != null) instance.CifsShareBasePath = dto.CifsShareBasePath;
|
||||
if (dto.CifsShareName != null) instance.CifsShareName = dto.CifsShareName;
|
||||
if (dto.CifsShareFolder != null) instance.CifsShareFolder = dto.CifsShareFolder;
|
||||
if (dto.CifsUsername != null) instance.CifsUsername = dto.CifsUsername;
|
||||
if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword;
|
||||
if (dto.CifsExtraOptions != null) instance.CifsExtraOptions = dto.CifsExtraOptions;
|
||||
@@ -324,12 +400,13 @@ public class InstanceService
|
||||
|
||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
// Use per-instance CIFS credentials
|
||||
var cifsServer = instance.CifsServer;
|
||||
var cifsShareBasePath = instance.CifsShareBasePath;
|
||||
var cifsUsername = instance.CifsUsername;
|
||||
var cifsPassword = instance.CifsPassword;
|
||||
var cifsOptions = instance.CifsExtraOptions ?? "file_mode=0777,dir_mode=0777";
|
||||
// Use per-instance CIFS credentials, falling back to global settings
|
||||
var cifsServer = instance.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
|
||||
var cifsShareName = instance.CifsShareName ?? await _settings.GetAsync(SettingsService.CifsShareName);
|
||||
var cifsShareFolder = instance.CifsShareFolder ?? await _settings.GetAsync(SettingsService.CifsShareFolder);
|
||||
var cifsUsername = instance.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
|
||||
var cifsPassword = instance.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
|
||||
var cifsOptions = instance.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
|
||||
var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
@@ -340,6 +417,17 @@ public class InstanceService
|
||||
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
// ── Fetch template from git ─────────────────────────────────────
|
||||
var repoUrl = instance.TemplateRepoUrl;
|
||||
var repoPat = instance.TemplateRepoPat ?? await _settings.GetAsync(SettingsService.GitRepoPat);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
throw new InvalidOperationException("Git template repository URL is not configured. Set it in Settings → Git Repo URL.");
|
||||
|
||||
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
|
||||
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = instance.CustomerName,
|
||||
@@ -367,18 +455,19 @@ public class InstanceService
|
||||
PhpPostMaxSize = phpPostMaxSize,
|
||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||
IncludeNewt = true,
|
||||
PangolinEndpoint = pangolinEndpoint,
|
||||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsShareName = cifsShareName,
|
||||
CifsShareFolder = cifsShareFolder,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
_logger.LogInformation("CIFS render values (update): server={CifsServer}, share={CifsShareName}, folder={CifsShareFolder}, user={CifsUsername}",
|
||||
cifsServer, cifsShareName, cifsShareFolder, cifsUsername);
|
||||
|
||||
var composeYaml = _compose.Render(templateConfig.Yaml, renderCtx);
|
||||
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
@@ -387,6 +476,30 @@ public class InstanceService
|
||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// Ensure bind-mount directories exist on the remote host
|
||||
if (!string.IsNullOrWhiteSpace(instance.ThemeHostPath))
|
||||
await _docker.EnsureDirectoryAsync(instance.ThemeHostPath);
|
||||
|
||||
// Ensure SMB share folders exist
|
||||
if (!string.IsNullOrWhiteSpace(cifsServer) && !string.IsNullOrWhiteSpace(cifsShareName))
|
||||
{
|
||||
var abbrevLower = instance.CustomerAbbrev;
|
||||
var smbFolders = new[]
|
||||
{
|
||||
$"{abbrevLower}-cms-custom",
|
||||
$"{abbrevLower}-cms-backup",
|
||||
$"{abbrevLower}-cms-library",
|
||||
$"{abbrevLower}-cms-userscripts",
|
||||
$"{abbrevLower}-cms-ca-certs",
|
||||
};
|
||||
_logger.LogInformation("Ensuring SMB share folders exist on //{Server}/{Share}", cifsServer, cifsShareName);
|
||||
await _docker.EnsureSmbFoldersAsync(cifsServer, cifsShareName, cifsUsername ?? string.Empty, cifsPassword ?? string.Empty, smbFolders, cifsShareFolder);
|
||||
}
|
||||
|
||||
// Remove stale CIFS volumes so Docker recreates them with current settings
|
||||
_logger.LogInformation("Removing stale CIFS volumes for stack {StackName} to ensure fresh mount options", instance.StackName);
|
||||
await _docker.RemoveStackVolumesAsync(instance.StackName);
|
||||
|
||||
var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");
|
||||
|
||||
@@ -51,7 +51,8 @@ public class SettingsService
|
||||
|
||||
// CIFS
|
||||
public const string CifsServer = "Cifs.Server";
|
||||
public const string CifsShareBasePath = "Cifs.ShareBasePath";
|
||||
public const string CifsShareName = "Cifs.ShareName";
|
||||
public const string CifsShareFolder = "Cifs.ShareFolder";
|
||||
public const string CifsUsername = "Cifs.Username";
|
||||
public const string CifsPassword = "Cifs.Password";
|
||||
public const string CifsOptions = "Cifs.Options";
|
||||
|
||||
@@ -118,11 +118,11 @@ public class App : Application
|
||||
// SSH services (singletons — maintain connections)
|
||||
services.AddSingleton<SshConnectionService>();
|
||||
|
||||
// Docker services via SSH (scoped so they get fresh per-operation context)
|
||||
services.AddTransient<SshDockerCliService>();
|
||||
services.AddTransient<SshDockerSecretsService>();
|
||||
services.AddTransient<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
|
||||
services.AddTransient<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
|
||||
// Docker services via SSH (singletons — SetHost() must persist across scopes)
|
||||
services.AddSingleton<SshDockerCliService>();
|
||||
services.AddSingleton<SshDockerSecretsService>();
|
||||
services.AddSingleton<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
|
||||
services.AddSingleton<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
|
||||
|
||||
// Core services
|
||||
services.AddTransient<SettingsService>();
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
|
||||
<PackageReference Include="MySqlConnector" Version="2.5.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
|
||||
@@ -151,9 +151,145 @@ public class SshDockerCliService : IDockerCliService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureDirectoryAsync(string path)
|
||||
{
|
||||
EnsureHost();
|
||||
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"mkdir -p {path}");
|
||||
if (exitCode != 0)
|
||||
_logger.LogWarning("Failed to create directory {Path} on {Host}: {Error}", path, _currentHost!.Label, stderr);
|
||||
else
|
||||
_logger.LogInformation("Ensured directory exists on {Host}: {Path}", _currentHost!.Label, path);
|
||||
return exitCode == 0;
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureSmbFoldersAsync(
|
||||
string cifsServer,
|
||||
string cifsShareName,
|
||||
string cifsUsername,
|
||||
string cifsPassword,
|
||||
IEnumerable<string> folderNames,
|
||||
string? cifsShareFolder = null)
|
||||
{
|
||||
EnsureHost();
|
||||
var allSucceeded = true;
|
||||
var subFolder = (cifsShareFolder ?? string.Empty).Trim('/');
|
||||
|
||||
// If a subfolder is specified, ensure it exists first
|
||||
if (!string.IsNullOrEmpty(subFolder))
|
||||
{
|
||||
var mkdirCmd = $"smbclient //{cifsServer}/{cifsShareName} -U '{cifsUsername}%{cifsPassword}' -c 'mkdir {subFolder}' 2>&1";
|
||||
var (_, mkdirOut, _) = await _ssh.RunCommandAsync(_currentHost!, mkdirCmd);
|
||||
var mkdirOutput = mkdirOut ?? string.Empty;
|
||||
|
||||
var alreadyExists = mkdirOutput.Contains("NT_STATUS_OBJECT_NAME_COLLISION", StringComparison.OrdinalIgnoreCase)
|
||||
|| mkdirOutput.Contains("already exists", StringComparison.OrdinalIgnoreCase);
|
||||
var success = alreadyExists || !mkdirOutput.Contains("NT_STATUS_", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (success)
|
||||
_logger.LogInformation("SMB subfolder ensured: //{Server}/{Share}/{Folder}", cifsServer, cifsShareName, subFolder);
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to create SMB subfolder //{Server}/{Share}/{Folder}: {Output}",
|
||||
cifsServer, cifsShareName, subFolder, mkdirOutput.Trim());
|
||||
allSucceeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the target path prefix for volume folders
|
||||
var pathPrefix = string.IsNullOrEmpty(subFolder) ? string.Empty : $"{subFolder}/";
|
||||
|
||||
foreach (var folder in folderNames)
|
||||
{
|
||||
var targetFolder = $"{pathPrefix}{folder}";
|
||||
// Run smbclient on the remote Docker host to create the folder on the share.
|
||||
// NT_STATUS_OBJECT_NAME_COLLISION means it already exists — treat as success.
|
||||
var cmd = $"smbclient //{cifsServer}/{cifsShareName} -U '{cifsUsername}%{cifsPassword}' -c 'mkdir {targetFolder}' 2>&1";
|
||||
var (_, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd);
|
||||
var output = stdout ?? string.Empty;
|
||||
|
||||
var exists = output.Contains("NT_STATUS_OBJECT_NAME_COLLISION", StringComparison.OrdinalIgnoreCase)
|
||||
|| output.Contains("already exists", StringComparison.OrdinalIgnoreCase);
|
||||
var ok = exists || !output.Contains("NT_STATUS_", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (ok)
|
||||
_logger.LogInformation("SMB folder ensured: //{Server}/{Share}/{Folder}", cifsServer, cifsShareName, targetFolder);
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to create SMB folder //{Server}/{Share}/{Folder}: {Output}",
|
||||
cifsServer, cifsShareName, targetFolder, output.Trim());
|
||||
allSucceeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
return allSucceeded;
|
||||
}
|
||||
|
||||
private void EnsureHost()
|
||||
{
|
||||
if (_currentHost == null)
|
||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveStackVolumesAsync(string stackName)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
// ── 1. Remove the stack first so containers release the volumes ─────
|
||||
_logger.LogInformation("Removing stack {StackName} before volume cleanup", stackName);
|
||||
var (rmExit, _, rmErr) = await _ssh.RunCommandAsync(_currentHost!,
|
||||
$"docker stack rm {stackName} 2>&1 || true");
|
||||
if (rmExit != 0)
|
||||
_logger.LogWarning("Stack rm returned non-zero for {StackName}: {Err}", stackName, rmErr);
|
||||
|
||||
// Give Swarm a moment to tear down containers on all nodes
|
||||
await Task.Delay(5000);
|
||||
|
||||
// ── 2. Clean volumes on the local (manager) node ────────────────────
|
||||
var localCmd = $"docker volume ls --filter \"name={stackName}_\" -q | xargs -r docker volume rm 2>&1 || true";
|
||||
var (_, localOut, _) = await _ssh.RunCommandAsync(_currentHost!, localCmd);
|
||||
if (!string.IsNullOrEmpty(localOut?.Trim()))
|
||||
_logger.LogInformation("Volume cleanup (manager): {Output}", localOut!.Trim());
|
||||
|
||||
// ── 3. Clean volumes on ALL swarm nodes via a temporary global service ──
|
||||
// This deploys a short-lived container on every node that mounts the Docker
|
||||
// socket and removes matching volumes. This handles worker nodes that the
|
||||
// orchestrator has no direct SSH access to.
|
||||
var cleanupSvcName = $"vol-cleanup-{stackName}".Replace("_", "-");
|
||||
|
||||
// Remove leftover cleanup service from a previous run (if any)
|
||||
await _ssh.RunCommandAsync(_currentHost!,
|
||||
$"docker service rm {cleanupSvcName} 2>/dev/null || true");
|
||||
|
||||
var createCmd = string.Join(" ",
|
||||
"docker service create",
|
||||
"--detach",
|
||||
"--mode global",
|
||||
"--restart-condition none",
|
||||
$"--name {cleanupSvcName}",
|
||||
"--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock",
|
||||
"docker:cli",
|
||||
"sh", "-c",
|
||||
$"'docker volume ls -q --filter name={stackName}_ | xargs -r docker volume rm 2>&1; echo done'");
|
||||
|
||||
_logger.LogInformation("Deploying global volume-cleanup service on all swarm nodes for {StackName}", stackName);
|
||||
var (svcExit, svcOut, svcErr) = await _ssh.RunCommandAsync(_currentHost!, createCmd);
|
||||
|
||||
if (svcExit != 0)
|
||||
{
|
||||
_logger.LogWarning("Global volume cleanup service creation failed: {Err}", svcErr);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Wait for the cleanup tasks to finish on all nodes
|
||||
_logger.LogInformation("Waiting for volume cleanup tasks to complete on all nodes...");
|
||||
await Task.Delay(10000);
|
||||
}
|
||||
|
||||
// Remove the cleanup service
|
||||
await _ssh.RunCommandAsync(_currentHost!,
|
||||
$"docker service rm {cleanupSvcName} 2>/dev/null || true");
|
||||
|
||||
_logger.LogInformation("Volume cleanup complete for stack {StackName}", stackName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
|
||||
// CIFS / SMB credentials (per-instance, defaults loaded from global settings)
|
||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareName = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareFolder = string.Empty;
|
||||
[ObservableProperty] private string _cifsUsername = string.Empty;
|
||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
|
||||
@@ -110,7 +111,8 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
using var scope = _services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
|
||||
CifsShareBasePath = await settings.GetAsync(SettingsService.CifsShareBasePath) ?? string.Empty;
|
||||
CifsShareName = await settings.GetAsync(SettingsService.CifsShareName) ?? string.Empty;
|
||||
CifsShareFolder = await settings.GetAsync(SettingsService.CifsShareFolder) ?? string.Empty;
|
||||
CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty;
|
||||
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
|
||||
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
@@ -148,7 +150,6 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
dockerCli.SetHost(SelectedSshHost);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(SelectedSshHost);
|
||||
var ssh = _services.GetRequiredService<SshConnectionService>();
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
@@ -161,12 +162,11 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
SetProgress(20, "Generating secrets...");
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
|
||||
// ── Step 3: Create MySQL database + user via SSH ───────────────
|
||||
// ── Step 3: Create MySQL database + user via direct TCP ────────
|
||||
SetProgress(35, "Creating MySQL database and user...");
|
||||
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
|
||||
Abbrev,
|
||||
mysqlPassword,
|
||||
cmd => ssh.RunCommandAsync(SelectedSshHost, cmd));
|
||||
mysqlPassword);
|
||||
|
||||
AppendOutput($"[MySQL] {mysqlMsg}");
|
||||
if (!mysqlOk)
|
||||
@@ -195,7 +195,8 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
||||
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
||||
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
|
||||
CifsShareBasePath = string.IsNullOrWhiteSpace(CifsShareBasePath) ? null : CifsShareBasePath.Trim(),
|
||||
CifsShareName = string.IsNullOrWhiteSpace(CifsShareName) ? null : CifsShareName.Trim(),
|
||||
CifsShareFolder = string.IsNullOrWhiteSpace(CifsShareFolder) ? null : CifsShareFolder.Trim(),
|
||||
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
|
||||
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
|
||||
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MySqlConnector;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
@@ -42,7 +43,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
|
||||
// ── CIFS ────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareName = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareFolder = string.Empty;
|
||||
[ObservableProperty] private string _cifsUsername = string.Empty;
|
||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
|
||||
@@ -100,7 +102,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
|
||||
// CIFS
|
||||
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty);
|
||||
CifsShareBasePath = await svc.GetAsync(SettingsService.CifsShareBasePath, string.Empty);
|
||||
CifsShareName = await svc.GetAsync(SettingsService.CifsShareName, string.Empty);
|
||||
CifsShareFolder = await svc.GetAsync(SettingsService.CifsShareFolder, string.Empty);
|
||||
CifsUsername = await svc.GetAsync(SettingsService.CifsUsername, string.Empty);
|
||||
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
|
||||
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
@@ -166,7 +169,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
|
||||
// CIFS
|
||||
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsShareBasePath, NullIfEmpty(CifsShareBasePath), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsShareName, NullIfEmpty(CifsShareName), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsShareFolder, NullIfEmpty(CifsShareFolder), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
|
||||
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
|
||||
@@ -208,30 +212,34 @@ public partial class SettingsViewModel : ObservableObject
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Testing MySQL connection via SSH...";
|
||||
StatusMessage = "Testing MySQL connection...";
|
||||
try
|
||||
{
|
||||
// The test runs a mysql --version or a simple SELECT 1 query via SSH
|
||||
// We need an SshHost to route through — use the first available
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OTSSignsOrchestrator.Core.Data.XiboContext>();
|
||||
var host = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
|
||||
.FirstOrDefaultAsync(db.SshHosts);
|
||||
if (!int.TryParse(MySqlPort, out var port))
|
||||
port = 3306;
|
||||
|
||||
if (host == null)
|
||||
var csb = new MySqlConnectionStringBuilder
|
||||
{
|
||||
StatusMessage = "No SSH hosts configured. Add one in the Hosts page first.";
|
||||
return;
|
||||
}
|
||||
Server = MySqlHost,
|
||||
Port = (uint)port,
|
||||
UserID = MySqlAdminUser,
|
||||
Password = MySqlAdminPassword,
|
||||
ConnectionTimeout = 10,
|
||||
SslMode = MySqlSslMode.Preferred,
|
||||
};
|
||||
|
||||
var ssh = _services.GetRequiredService<Services.SshConnectionService>();
|
||||
var port = int.TryParse(MySqlPort, out var p) ? p : 3306;
|
||||
var cmd = $"mysql -h {MySqlHost} -P {port} -u {MySqlAdminUser} -p'{MySqlAdminPassword.Replace("'", "'\\''")}' -e 'SELECT 1' 2>&1";
|
||||
var (exitCode, stdout, stderr) = await ssh.RunCommandAsync(host, cmd);
|
||||
await using var connection = new MySqlConnection(csb.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
StatusMessage = exitCode == 0
|
||||
? $"MySQL connection successful via {host.Label}."
|
||||
: $"MySQL connection failed: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}";
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1";
|
||||
await cmd.ExecuteScalarAsync();
|
||||
|
||||
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port}).";
|
||||
}
|
||||
catch (MySqlException ex)
|
||||
{
|
||||
StatusMessage = $"MySQL connection failed: {ex.Message}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -54,8 +54,11 @@
|
||||
<TextBlock Text="CIFS Server" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" />
|
||||
|
||||
<TextBlock Text="Share Base Path" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareBasePath}" Watermark="e.g. /share/cms" />
|
||||
<TextBlock Text="Share Name" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareName}" Watermark="e.g. u548897-sub1" />
|
||||
|
||||
<TextBlock Text="Share Folder (optional)" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareFolder}" Watermark="e.g. ots_cms (leave empty for share root)" />
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsUsername}" Watermark="SMB username" />
|
||||
|
||||
@@ -127,8 +127,11 @@
|
||||
<TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsServer}" Watermark="nas.local" />
|
||||
|
||||
<TextBlock Text="Share Base Path" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareBasePath}" Watermark="/share/cms" />
|
||||
<TextBlock Text="Share Name" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareName}" Watermark="u548897-sub1" />
|
||||
|
||||
<TextBlock Text="Share Folder (optional)" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareFolder}" Watermark="ots_cms (leave empty for share root)" />
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsUsername}" Watermark="smbuser" />
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
@page "/instances/create"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
|
||||
@inject InstanceService InstanceSvc
|
||||
@inject NavigationManager Navigation
|
||||
@inject IOptions<InstanceDefaultsOptions> Defaults
|
||||
@using Microsoft.Extensions.Options
|
||||
@using OTSSignsOrchestrator.Configuration
|
||||
|
||||
<PageTitle>Create Instance - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
<h4 class="mb-4">New CMS Instance</h4>
|
||||
|
||||
<div class="row g-4">
|
||||
@* ── Left column: form ── *@
|
||||
<div class="col-lg-5">
|
||||
<EditForm Model="model" OnValidSubmit="HandleSubmit" FormName="createInstance">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="alert alert-danger py-2 small" />
|
||||
|
||||
<fieldset disabled="@deploying">
|
||||
|
||||
@* ── Customer ── *@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Customer Name</label>
|
||||
<InputText @bind-Value="model.CustomerName" class="form-control"
|
||||
placeholder="Acme Corporation"
|
||||
@oninput="OnNameInput" />
|
||||
<div class="form-text">Display name — stored as a comment in the stack file.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Abbreviation <span class="text-muted fw-normal">(3 letters)</span></label>
|
||||
<InputText @bind-Value="model.CustomerAbbrev" class="form-control font-monospace text-uppercase"
|
||||
placeholder="ACM" maxlength="3"
|
||||
style="text-transform:uppercase; letter-spacing:.15em; width:6rem;"
|
||||
@oninput="OnAbbrevInput" />
|
||||
<div class="form-text">3 letters used as a prefix for every stack resource.</div>
|
||||
</div>
|
||||
|
||||
@* ── Optional overrides ── *@
|
||||
<details class="mb-3">
|
||||
<summary class="text-muted small" style="cursor:pointer;">Advanced overrides</summary>
|
||||
<div class="mt-2 ps-2 border-start">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Template Repo URL <span class="text-muted">(leave blank to use default)</span></label>
|
||||
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control form-control-sm"
|
||||
placeholder="@Defaults.Value.TemplateRepoUrl" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Placement Constraints <span class="text-muted">(comma-separated)</span></label>
|
||||
<InputText @bind-Value="constraintsText" class="form-control form-control-sm"
|
||||
placeholder="node.labels.xibo==true" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col mb-2">
|
||||
<label class="form-label small">Xibo Client ID</label>
|
||||
<InputText @bind-Value="model.XiboUsername" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col mb-2">
|
||||
<label class="form-label small">Xibo Client Secret</label>
|
||||
<InputText @bind-Value="model.XiboPassword" class="form-control form-control-sm" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-success px-4" disabled="@deploying">
|
||||
@if (deploying)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
|
||||
<span>Deploying…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Deploy</span>
|
||||
}
|
||||
</button>
|
||||
<a href="/" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
</EditForm>
|
||||
|
||||
@if (!string.IsNullOrEmpty(resultMessage))
|
||||
{
|
||||
<div class="alert @(resultSuccess ? "alert-success" : "alert-danger") mt-3">
|
||||
@resultMessage
|
||||
@if (resultSuccess && createdInstanceId.HasValue)
|
||||
{
|
||||
<a href="instances/@createdInstanceId" class="alert-link ms-2">View Instance →</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Right column: live preview ── *@
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100 border-0 bg-body-tertiary">
|
||||
<div class="card-header border-0 bg-body-tertiary d-flex align-items-center gap-2">
|
||||
<span class="fw-semibold">Resource Preview</span>
|
||||
@if (AbbrevIsValid)
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">@Abbrev</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary border">enter abbreviation</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body pt-2">
|
||||
|
||||
@* Stack *@
|
||||
<PreviewRow Label="Stack name" Value="@Abbrev" Icon="layers" />
|
||||
<PreviewRow Label="Overlay network" Value="@($"{Abbrev}-net")" Icon="share" />
|
||||
|
||||
<hr class="my-2" />
|
||||
|
||||
@* Services *@
|
||||
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Services</div>
|
||||
<PreviewRow Label="Web (Xibo CMS)" Value="@($"{Abbrev}-web")" />
|
||||
<PreviewRow Label="Memcached" Value="@($"{Abbrev}-memcached")" />
|
||||
<PreviewRow Label="QuickChart" Value="@($"{Abbrev}-quickchart")" />
|
||||
<PreviewRow Label="Newt (tunnel)" Value="@($"{Abbrev}-newt")" Muted="true" Note="(if template provides NEWT_ID)" />
|
||||
|
||||
<hr class="my-2" />
|
||||
|
||||
@* Volumes *@
|
||||
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">CIFS Volumes</div>
|
||||
@foreach (var vol in new[] { "cms-custom", "cms-backup", "cms-library", "cms-userscripts", "cms-ca-certs" })
|
||||
{
|
||||
<PreviewRow Label="@vol" Value="@($"{Abbrev}-{vol}")" />
|
||||
}
|
||||
<PreviewRow Label="db-data" Value="@($"{Abbrev}-db-data")" Note="(local driver)" Muted="true" />
|
||||
|
||||
<hr class="my-2" />
|
||||
|
||||
@* Docker secret *@
|
||||
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Docker Secret</div>
|
||||
<PreviewRow Label="MySQL password" Value="@($"{Abbrev}_mysql_password")" Icon="key" />
|
||||
|
||||
<hr class="my-2" />
|
||||
|
||||
@* External config *@
|
||||
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">External Resources</div>
|
||||
<PreviewRow Label="CMS URL" Value="@CmsServer" Icon="globe" />
|
||||
<PreviewRow Label="MySQL database" Value="@MySqlDb" />
|
||||
<PreviewRow Label="MySQL user" Value="@MySqlUser" />
|
||||
<PreviewRow Label="Theme host path" Value="@ThemePath" />
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl))
|
||||
{
|
||||
<hr class="my-2" />
|
||||
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Template</div>
|
||||
<PreviewRow Label="Repo" Value="@Defaults.Value.TemplateRepoUrl" Icon="git-branch" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning py-1 px-2 small mt-2 mb-0">
|
||||
⚠ No template repo configured. <a href="/settings">Set it in Settings</a> before deploying.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Inline sub-component for a labelled preview row *@
|
||||
@code {
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
private CreateInstanceDto model = new();
|
||||
private string? constraintsText;
|
||||
private bool deploying;
|
||||
private string? resultMessage;
|
||||
private bool resultSuccess;
|
||||
private Guid? createdInstanceId;
|
||||
|
||||
// ── Derived / preview ────────────────────────────────────────────────────
|
||||
|
||||
private string Abbrev =>
|
||||
string.IsNullOrWhiteSpace(model.CustomerAbbrev)
|
||||
? "???"
|
||||
: model.CustomerAbbrev.ToLowerInvariant()[..Math.Min(3, model.CustomerAbbrev.Length)];
|
||||
|
||||
private bool AbbrevIsValid =>
|
||||
!string.IsNullOrWhiteSpace(model.CustomerAbbrev) && model.CustomerAbbrev.Length == 3;
|
||||
|
||||
private string Apply(string template) => template.Replace("{abbrev}", Abbrev);
|
||||
|
||||
private string CmsServer => Apply(Defaults.Value.CmsServerNameTemplate);
|
||||
private string MySqlDb => Apply(Defaults.Value.MySqlDatabaseTemplate);
|
||||
private string MySqlUser => Apply(Defaults.Value.MySqlUserTemplate);
|
||||
private string ThemePath => string.IsNullOrWhiteSpace(model.ThemeHostPath)
|
||||
? Defaults.Value.ThemeHostPath
|
||||
: model.ThemeHostPath;
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnNameInput(ChangeEventArgs e)
|
||||
{
|
||||
// Auto-suggest abbreviation from first word if user hasn't typed one yet
|
||||
if (string.IsNullOrWhiteSpace(model.CustomerAbbrev))
|
||||
{
|
||||
var word = (e.Value?.ToString() ?? "")
|
||||
.Split([' ', '-', '_'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault(w => w.Length > 0);
|
||||
if (!string.IsNullOrEmpty(word))
|
||||
model.CustomerAbbrev = word[..Math.Min(3, word.Length)].ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAbbrevInput(ChangeEventArgs e)
|
||||
{
|
||||
// Enforce uppercase in the bound value immediately
|
||||
model.CustomerAbbrev = (e.Value?.ToString() ?? "").ToUpperInvariant();
|
||||
}
|
||||
|
||||
// ── Submit ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
deploying = true;
|
||||
resultMessage = null;
|
||||
model.CustomerAbbrev = model.CustomerAbbrev?.ToUpperInvariant() ?? string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(constraintsText))
|
||||
{
|
||||
model.Constraints = constraintsText
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(c => c.Trim())
|
||||
.Where(c => !string.IsNullOrEmpty(c))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var result = await InstanceSvc.CreateInstanceAsync(model);
|
||||
|
||||
resultSuccess = result.Success;
|
||||
resultMessage = result.Success
|
||||
? $"Instance '{result.StackName}' deployed in {result.DurationMs}ms ({result.ServiceCount} services)."
|
||||
: $"Deployment failed: {result.ErrorMessage}";
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
var page = await InstanceSvc.ListInstancesAsync(1, 1, Abbrev);
|
||||
createdInstanceId = page.Items.FirstOrDefault()?.Id;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
resultSuccess = false;
|
||||
resultMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
deploying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inline sub-component: preview row ────────────────────────────────────
|
||||
|
||||
private RenderFragment PreviewRow(string Label, string Value, string? Icon = null, string? Note = null, bool Muted = false) =>
|
||||
@<div class="d-flex align-items-baseline gap-2 mb-1 small">
|
||||
<span class="text-muted" style="min-width:9rem;">@Label</span>
|
||||
<code class="@(Muted ? "text-secondary" : "text-body") flex-fill" style="font-size:.875em;">@Value</code>
|
||||
@if (!string.IsNullOrEmpty(Note))
|
||||
{
|
||||
<span class="text-muted fst-italic" style="font-size:.8em;">@Note</span>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
<PageTitle>Create Instance - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
<h3>Create CMS Instance</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<EditForm Model="model" OnValidSubmit="HandleSubmit" FormName="createInstance">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" />
|
||||
|
||||
<fieldset disabled="@deploying">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Customer Details</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Customer Name</label>
|
||||
<InputText @bind-Value="model.CustomerName" class="form-control"
|
||||
placeholder="Acme Corporation"
|
||||
@oninput="OnCustomerNameInput" />
|
||||
<div class="form-text">Full display name — stored as a comment in the stack file.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">3-Letter Abbreviation</label>
|
||||
<div class="input-group">
|
||||
<InputText @bind-Value="model.CustomerAbbrev" class="form-control text-uppercase"
|
||||
placeholder="ACM" maxlength="3" style="text-transform:uppercase;" />
|
||||
<span class="input-group-text text-muted" style="font-size:0.85em;">
|
||||
Stack: <strong class="ms-1">@(model.CustomerAbbrev?.ToLowerInvariant() ?? "…")</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Exactly 3 letters (a–z). Used as the prefix for all stack resources:
|
||||
services, volumes, and network names.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Show what will be auto-configured from settings *@
|
||||
<div class="card mb-3 border-secondary">
|
||||
<div class="card-header bg-light text-muted">Auto-configured from Settings</div>
|
||||
<div class="card-body text-muted small">
|
||||
<div class="row">
|
||||
<div class="col-6 mb-1"><strong>CMS server:</strong><br />@(Defaults.Value.CmsServerNameTemplate.Replace("{abbrev}", AbbrevPreview))</div>
|
||||
<div class="col-6 mb-1"><strong>Theme path:</strong><br />@Defaults.Value.ThemeHostPath</div>
|
||||
<div class="col-6 mb-1"><strong>MySQL DB:</strong><br />@(Defaults.Value.MySqlDatabaseTemplate.Replace("{abbrev}", AbbrevPreview))</div>
|
||||
<div class="col-6 mb-1"><strong>MySQL user:</strong><br />@(Defaults.Value.MySqlUserTemplate.Replace("{abbrev}", AbbrevPreview))</div>
|
||||
<div class="col-6 mb-1"><strong>SMTP server:</strong><br />@Defaults.Value.SmtpServer</div>
|
||||
<div class="col-6 mb-1"><strong>Template repo:</strong><br />@(string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl) ? "⚠️ Not configured" : Defaults.Value.TemplateRepoUrl)</div>
|
||||
</div>
|
||||
<a href="/settings" class="small">Edit defaults in Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Optional overrides *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Optional Overrides</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Template Repo URL <span class="text-muted">(overrides setting)</span></label>
|
||||
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control"
|
||||
placeholder="Leave blank to use configured default" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Theme Host Path <span class="text-muted">(overrides setting default: /cms/ots-theme)</span></label>
|
||||
<InputText @bind-Value="model.ThemeHostPath" class="form-control"
|
||||
placeholder="/cms/ots-theme" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Placement Constraints <span class="text-muted">(comma-separated)</span></label>
|
||||
<InputText @bind-Value="constraintsText" class="form-control"
|
||||
placeholder="node.labels.xibo==true, node.role==manager" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Xibo Credentials (optional) *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Xibo API Credentials <span class="text-muted fw-normal">(optional)</span></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">Provide credentials to enable API connectivity testing after deploy.</p>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Client ID</label>
|
||||
<InputText @bind-Value="model.XiboUsername" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Client Secret</label>
|
||||
<InputText @bind-Value="model.XiboPassword" class="form-control" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@deploying">
|
||||
@(deploying ? "Deploying…" : "Deploy Instance")
|
||||
</button>
|
||||
<a href="/" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</fieldset>
|
||||
</EditForm>
|
||||
|
||||
@if (!string.IsNullOrEmpty(resultMessage))
|
||||
{
|
||||
<div class="alert @(resultSuccess ? "alert-success" : "alert-danger") mt-3">
|
||||
@resultMessage
|
||||
@if (resultSuccess && createdInstanceId.HasValue)
|
||||
{
|
||||
<a href="instances/@createdInstanceId" class="alert-link ms-2">View Details</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private CreateInstanceDto model = new();
|
||||
|
||||
private string? constraintsText;
|
||||
private bool deploying;
|
||||
private string? resultMessage;
|
||||
private bool resultSuccess;
|
||||
private Guid? createdInstanceId;
|
||||
|
||||
/// <summary>Live abbrev preview (lowercase, 3 chars max) for the settings preview card.</summary>
|
||||
private string AbbrevPreview =>
|
||||
string.IsNullOrWhiteSpace(model.CustomerAbbrev)
|
||||
? "???"
|
||||
: model.CustomerAbbrev.ToLowerInvariant()[..Math.Min(3, model.CustomerAbbrev.Length)];
|
||||
|
||||
private void OnCustomerNameInput(ChangeEventArgs e)
|
||||
{
|
||||
// Auto-suggest abbreviation from first 3 letters of first word
|
||||
if (string.IsNullOrWhiteSpace(model.CustomerAbbrev))
|
||||
{
|
||||
var word = (e.Value?.ToString() ?? "").Split(' ', '-', '_').FirstOrDefault(w => w.Length > 0);
|
||||
if (!string.IsNullOrEmpty(word))
|
||||
model.CustomerAbbrev = word[..Math.Min(3, word.Length)].ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
deploying = true;
|
||||
resultMessage = null;
|
||||
model.CustomerAbbrev = model.CustomerAbbrev?.ToUpperInvariant() ?? string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(constraintsText))
|
||||
{
|
||||
model.Constraints = constraintsText
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(c => c.Trim())
|
||||
.Where(c => !string.IsNullOrEmpty(c))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var result = await InstanceSvc.CreateInstanceAsync(model);
|
||||
|
||||
resultSuccess = result.Success;
|
||||
resultMessage = result.Success
|
||||
? $"Instance '{result.StackName}' deployed successfully in {result.DurationMs}ms ({result.ServiceCount} services)."
|
||||
: $"Deployment failed: {result.ErrorMessage}";
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
var instance = (await InstanceSvc.ListInstancesAsync(1, 1, model.CustomerAbbrev.ToLowerInvariant())).Items.FirstOrDefault();
|
||||
createdInstanceId = instance?.Id;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
resultSuccess = false;
|
||||
resultMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
deploying = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
namespace OTSSignsOrchestrator.Configuration;
|
||||
|
||||
public class FileLoggingOptions
|
||||
{
|
||||
public const string SectionName = "FileLogging";
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string Path { get; set; } = "/var/log/xibo-admin";
|
||||
public string RollingInterval { get; set; } = "Day";
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
public long FileSizeLimitBytes { get; set; } = 100 * 1024 * 1024; // 100MB
|
||||
}
|
||||
|
||||
public class AuthenticationOptions
|
||||
{
|
||||
public const string SectionName = "Authentication";
|
||||
public string LocalAdminToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class GitOptions
|
||||
{
|
||||
public const string SectionName = "Git";
|
||||
public string CacheDir { get; set; } = "/var/cache/xibo-admin-templates";
|
||||
public int CacheTtlMinutes { get; set; } = 60;
|
||||
public int ShallowCloneDepth { get; set; } = 1;
|
||||
}
|
||||
|
||||
public class DockerOptions
|
||||
{
|
||||
public const string SectionName = "Docker";
|
||||
public string SocketPath { get; set; } = "unix:///var/run/docker.sock";
|
||||
public List<string> DefaultConstraints { get; set; } = new() { "node.labels.xibo==true" };
|
||||
public int DeployTimeoutSeconds { get; set; } = 30;
|
||||
public bool ValidateBeforeDeploy { get; set; } = true;
|
||||
}
|
||||
|
||||
public class XiboDefaultImages
|
||||
{
|
||||
public string Cms { get; set; } = "ghcr.io/xibosignage/xibo-cms:release-4.4.0";
|
||||
public string Mysql { get; set; } = "mysql:8.4";
|
||||
public string Memcached { get; set; } = "memcached:alpine";
|
||||
public string QuickChart { get; set; } = "ianw/quickchart";
|
||||
}
|
||||
|
||||
public class XiboOptions
|
||||
{
|
||||
public const string SectionName = "Xibo";
|
||||
public XiboDefaultImages DefaultImages { get; set; } = new();
|
||||
public int TestConnectionTimeoutSeconds { get; set; } = 10;
|
||||
}
|
||||
|
||||
public class DatabaseOptions
|
||||
{
|
||||
public const string SectionName = "Database";
|
||||
public string Provider { get; set; } = "Sqlite"; // Sqlite or PostgreSQL
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admin-level MySQL connection used by the orchestrator to provision new customer databases.
|
||||
/// Credentials are stored in app settings (encrypted at rest via Data Protection where available).
|
||||
/// The generated per-customer password is NEVER stored here — it is placed directly into a Docker secret.
|
||||
/// </summary>
|
||||
public class MySqlAdminOptions
|
||||
{
|
||||
public const string SectionName = "MySqlAdmin";
|
||||
public string Host { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 3306;
|
||||
public string AdminUser { get; set; } = "root";
|
||||
public string AdminPassword { get; set; } = string.Empty;
|
||||
/// <summary>If true, treat TLS/cert errors as non-fatal (useful for self-signed certs in dev).</summary>
|
||||
public bool AllowInsecureTls { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CIFS volume settings applied to every named Docker volume created for a new instance.
|
||||
/// The credentials file on the remote host is written ephemerally via SSH and deleted immediately after
|
||||
/// the docker volume create command completes.
|
||||
/// </summary>
|
||||
public class CifsOptions
|
||||
{
|
||||
public const string SectionName = "Cifs";
|
||||
/// <summary>UNC-style device path, e.g. //fileserver.local/xibo-data</summary>
|
||||
public string Device { get; set; } = string.Empty;
|
||||
/// <summary>Hostname/IP of the CIFS server for the addr= mount option.</summary>
|
||||
public string ServerAddr { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public string MountOptions { get; set; } = "vers=3.0,file_mode=0660,dir_mode=0770";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defaults sourced from the Settings page, used to pre-populate or complete instance creation
|
||||
/// without requiring the operator to retype them every time.
|
||||
/// </summary>
|
||||
public class InstanceDefaultsOptions
|
||||
{
|
||||
public const string SectionName = "InstanceDefaults";
|
||||
/// <summary>Default Git template repo URL (operator can override per-instance).</summary>
|
||||
public string TemplateRepoUrl { get; set; } = string.Empty;
|
||||
public string? TemplateRepoPat { get; set; }
|
||||
/// <summary>Template for CMS_SERVER_NAME. Use {abbrev} as placeholder, e.g. "{abbrev}x.ots-signs.com".</summary>
|
||||
public string CmsServerNameTemplate { get; set; } = "{abbrev}.ots-signs.com";
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
public string SmtpUsername { get; set; } = string.Empty;
|
||||
public string SmtpPassword { get; set; } = string.Empty;
|
||||
/// <summary>Base host HTTP port; each new instance auto-increments from this value.</summary>
|
||||
public int BaseHostHttpPort { get; set; } = 8080;
|
||||
/// <summary>Template for the theme host path. Use {abbrev} as placeholder.</summary>
|
||||
/// <summary>Static host path for the theme volume mount. Overridable per-instance.</summary>
|
||||
public string ThemeHostPath { get; set; } = "/cms/ots-theme";
|
||||
/// <summary>Template for the library CIFS volume sub-path. Use {abbrev} as placeholder.</summary>
|
||||
public string LibraryShareSubPath { get; set; } = "{abbrev}-cms-library";
|
||||
/// <summary>MySQL database name template. Use {abbrev}.</summary>
|
||||
public string MySqlDatabaseTemplate { get; set; } = "{abbrev}_cms_db";
|
||||
/// <summary>MySQL username template. Use {abbrev}.</summary>
|
||||
public string MySqlUserTemplate { get; set; } = "{abbrev}_cms";
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Configuration;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddXiboSwarmServices(this WebApplicationBuilder builder)
|
||||
{
|
||||
var config = builder.Configuration;
|
||||
|
||||
// --- Options ---
|
||||
builder.Services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
|
||||
builder.Services.Configure<AuthenticationOptions>(config.GetSection(AuthenticationOptions.SectionName));
|
||||
builder.Services.Configure<GitOptions>(config.GetSection(GitOptions.SectionName));
|
||||
builder.Services.Configure<DockerOptions>(config.GetSection(DockerOptions.SectionName));
|
||||
builder.Services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
||||
builder.Services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
|
||||
builder.Services.Configure<MySqlAdminOptions>(config.GetSection(MySqlAdminOptions.SectionName));
|
||||
builder.Services.Configure<CifsOptions>(config.GetSection(CifsOptions.SectionName));
|
||||
builder.Services.Configure<InstanceDefaultsOptions>(config.GetSection(InstanceDefaultsOptions.SectionName));
|
||||
|
||||
// --- Serilog ---
|
||||
ConfigureSerilog(builder);
|
||||
|
||||
// --- Data Protection (encrypts secrets at rest) ---
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(
|
||||
Path.Combine(builder.Environment.ContentRootPath, "keys")))
|
||||
.SetApplicationName("OTSSignsOrchestrator");
|
||||
|
||||
// --- Database ---
|
||||
var dbProvider = config.GetValue<string>("Database:Provider") ?? "Sqlite";
|
||||
var connStr = config.GetConnectionString("Default") ?? "Data Source=xibo-admin.db";
|
||||
|
||||
builder.Services.AddDbContext<XiboContext>(options =>
|
||||
{
|
||||
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
||||
options.UseNpgsql(connStr);
|
||||
else
|
||||
options.UseSqlite(connStr);
|
||||
});
|
||||
|
||||
// --- Authentication ---
|
||||
ConfigureAuthentication(builder);
|
||||
|
||||
// --- HTTP Clients ---
|
||||
builder.Services.AddHttpClient("XiboApi")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
// Accept self-signed certs in dev
|
||||
ServerCertificateCustomValidationCallback =
|
||||
builder.Environment.IsDevelopment()
|
||||
? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
: null!
|
||||
});
|
||||
builder.Services.AddHttpClient(); // Default factory
|
||||
|
||||
// --- Application Services ---
|
||||
builder.Services.AddScoped<GitTemplateService>();
|
||||
builder.Services.AddScoped<ComposeRenderService>();
|
||||
builder.Services.AddScoped<ComposeValidationService>();
|
||||
builder.Services.AddScoped<DockerCliService>();
|
||||
builder.Services.AddScoped<DockerSecretsService>();
|
||||
builder.Services.AddScoped<MySqlProvisionService>();
|
||||
builder.Services.AddScoped<XiboApiService>();
|
||||
builder.Services.AddScoped<InstanceService>();
|
||||
builder.Services.AddScoped<OidcProviderService>();
|
||||
|
||||
// --- API Controllers ---
|
||||
builder.Services.AddControllers();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void ConfigureSerilog(WebApplicationBuilder builder)
|
||||
{
|
||||
var logConfig = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("Application", "OTSSignsOrchestrator")
|
||||
.WriteTo.Console();
|
||||
|
||||
var fileLogging = builder.Configuration.GetSection(FileLoggingOptions.SectionName).Get<FileLoggingOptions>();
|
||||
if (fileLogging?.Enabled == true)
|
||||
{
|
||||
var logPath = fileLogging.Path;
|
||||
if (!Path.IsPathRooted(logPath))
|
||||
logPath = Path.Combine(builder.Environment.ContentRootPath, logPath);
|
||||
|
||||
Directory.CreateDirectory(logPath);
|
||||
|
||||
// App log
|
||||
logConfig.WriteTo.File(
|
||||
Path.Combine(logPath, "app-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: fileLogging.RetentionDays,
|
||||
fileSizeLimitBytes: fileLogging.FileSizeLimitBytes,
|
||||
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}");
|
||||
}
|
||||
|
||||
Log.Logger = logConfig.CreateLogger();
|
||||
builder.Host.UseSerilog();
|
||||
}
|
||||
|
||||
private static void ConfigureAuthentication(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/login";
|
||||
options.LogoutPath = "/logout";
|
||||
options.AccessDeniedPath = "/access-denied";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
options.SlidingExpiration = true;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, AdminTokenAuthHandler>(
|
||||
AppConstants.AdminTokenScheme, _ => { });
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("AdminOnly", policy =>
|
||||
policy.RequireRole(AppConstants.AdminRole));
|
||||
});
|
||||
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
}
|
||||
|
||||
public static WebApplication UseXiboSwarmMiddleware(this WebApplication app)
|
||||
{
|
||||
// Security headers
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
context.Response.Headers["X-Frame-Options"] = "DENY";
|
||||
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||
await next();
|
||||
});
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
// Health check
|
||||
app.MapGet("/healthz", () => Results.Ok(new
|
||||
{
|
||||
status = "healthy",
|
||||
timestamp = DateTime.UtcNow
|
||||
})).AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OTSSignsOrchestrator.Models.DTOs;
|
||||
|
||||
public class CreateInstanceDto
|
||||
{
|
||||
/// <summary>Full display name of the customer (stored as YAML comment).</summary>
|
||||
[Required, MaxLength(100)]
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>3-letter uppercase abbreviation used as prefix in all stack resource names.</summary>
|
||||
[Required, StringLength(3, MinimumLength = 3, ErrorMessage = "Abbreviation must be exactly 3 letters.")]
|
||||
[RegularExpression("^[a-zA-Z]{3}$", ErrorMessage = "Abbreviation must be 3 letters (a-z, A-Z).")]
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
|
||||
// TemplateRepoUrl and TemplateRepoPat are sourced from app settings (Settings page) and
|
||||
// optionally overridden here per-instance.
|
||||
[MaxLength(500)]
|
||||
public string? TemplateRepoUrl { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? TemplateRepoPat { get; set; }
|
||||
|
||||
/// <summary>Override the theme host path from settings (e.g. /cms/ots-theme).</summary>
|
||||
[MaxLength(500)]
|
||||
public string? ThemeHostPath { get; set; }
|
||||
|
||||
/// <summary>Comma-separated placement constraints.</summary>
|
||||
public List<string>? Constraints { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? XiboUsername { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? XiboPassword { get; set; }
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OTSSignsOrchestrator.Models.Entities;
|
||||
|
||||
public enum InstanceStatus
|
||||
{
|
||||
Deploying,
|
||||
Active,
|
||||
Error,
|
||||
Deleted
|
||||
}
|
||||
|
||||
public enum XiboApiTestStatus
|
||||
{
|
||||
Unknown,
|
||||
Success,
|
||||
Failed
|
||||
}
|
||||
|
||||
public class CmsInstance
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>3-letter lowercase abbreviation used as stack resource prefix (e.g. "ots").</summary>
|
||||
[Required, MaxLength(3)]
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string StackName { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string CmsServerName { get; set; } = string.Empty;
|
||||
|
||||
[Required, Range(1024, 65535)]
|
||||
public int HostHttpPort { get; set; }
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string ThemeHostPath { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string LibraryHostPath { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string SmtpUsername { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// JSON array of placement constraints, e.g. ["node.labels.xibo==true"]
|
||||
/// </summary>
|
||||
[MaxLength(2000)]
|
||||
public string? Constraints { get; set; }
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string TemplateRepoUrl { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? TemplateRepoPat { get; set; }
|
||||
|
||||
public DateTime? TemplateLastFetch { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? TemplateCacheKey { get; set; }
|
||||
|
||||
public InstanceStatus Status { get; set; } = InstanceStatus.Deploying;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypted Xibo admin username for API access.
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string? XiboUsername { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Encrypted Xibo admin password for API access.
|
||||
/// Never logged; encrypted at rest via Data Protection.
|
||||
/// </summary>
|
||||
[MaxLength(1000)]
|
||||
public string? XiboPassword { get; set; }
|
||||
|
||||
public XiboApiTestStatus XiboApiTestStatus { get; set; } = XiboApiTestStatus.Unknown;
|
||||
|
||||
public DateTime? XiboApiTestedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Soft delete marker.</summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ICollection<OperationLog> OperationLogs { get; set; } = new List<OperationLog>();
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Models.DTOs;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace OTSSignsOrchestrator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a single self-contained Compose v3.9 YAML from the template and user/settings inputs.
|
||||
/// The output file has no separate env_file — all environment variables are inlined.
|
||||
/// Service and volume names are prefixed with the 3-letter customer abbreviation.
|
||||
/// CIFS-backed named volumes are rendered with driver_opts so they can be created on the swarm.
|
||||
/// </summary>
|
||||
public class ComposeRenderService
|
||||
{
|
||||
private readonly XiboOptions _xiboOptions;
|
||||
private readonly DockerOptions _dockerOptions;
|
||||
private readonly CifsOptions _cifsOptions;
|
||||
private readonly ILogger<ComposeRenderService> _logger;
|
||||
|
||||
public ComposeRenderService(
|
||||
IOptions<XiboOptions> xiboOptions,
|
||||
IOptions<DockerOptions> dockerOptions,
|
||||
IOptions<CifsOptions> cifsOptions,
|
||||
ILogger<ComposeRenderService> logger)
|
||||
{
|
||||
_xiboOptions = xiboOptions.Value;
|
||||
_dockerOptions = dockerOptions.Value;
|
||||
_cifsOptions = cifsOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a final Compose YAML from the template + user inputs + secrets.
|
||||
/// </summary>
|
||||
public string Render(RenderContext ctx)
|
||||
{
|
||||
_logger.LogInformation("Rendering Compose for stack: {StackName} (abbrev={Abbrev})", ctx.StackName, ctx.CustomerAbbrev);
|
||||
|
||||
// Parse template YAML
|
||||
var yaml = new YamlStream();
|
||||
using (var reader = new StringReader(ctx.TemplateYaml))
|
||||
{
|
||||
yaml.Load(reader);
|
||||
}
|
||||
|
||||
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
|
||||
|
||||
// Ensure version
|
||||
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
|
||||
|
||||
// Process services
|
||||
EnsureServices(root, ctx);
|
||||
|
||||
// Process volumes
|
||||
EnsureVolumes(root, ctx);
|
||||
|
||||
// Process secrets
|
||||
EnsureSecrets(root, ctx);
|
||||
|
||||
// Serialize back to YAML
|
||||
using var writer = new StringWriter();
|
||||
yaml.Save(writer, assignAnchors: false);
|
||||
var output = writer.ToString();
|
||||
|
||||
// Clean up YAML stream terminators
|
||||
output = output.Replace("...\n", "").Replace("...", "");
|
||||
|
||||
// Prepend customer comment
|
||||
output = $"# Customer: {ctx.CustomerName}\n" + output;
|
||||
|
||||
_logger.LogDebug("Compose rendered: {ServiceCount} services, {SecretCount} secrets",
|
||||
GetServiceCount(root), ctx.SecretNames.Count);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private void EnsureServices(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
if (!root.Children.ContainsKey(new YamlScalarNode("services")))
|
||||
root.Children[new YamlScalarNode("services")] = new YamlMappingNode();
|
||||
|
||||
var services = (YamlMappingNode)root.Children[new YamlScalarNode("services")];
|
||||
|
||||
// Clear any services from template — we always build them deterministically
|
||||
services.Children.Clear();
|
||||
|
||||
EnsureCmsWeb(services, ctx);
|
||||
EnsureMemcached(services, ctx);
|
||||
EnsureQuickChart(services, ctx);
|
||||
EnsureNewt(services, ctx);
|
||||
}
|
||||
|
||||
private void EnsureCmsWeb(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var a = ctx.CustomerAbbrev;
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{a}-web")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Cms);
|
||||
|
||||
// Build environment — merge template.env first, then apply our required overrides
|
||||
var env = BuildEnvFromTemplate(ctx.TemplateEnvValues, ctx);
|
||||
svc.Children[new YamlScalarNode("environment")] = env;
|
||||
|
||||
// Ports
|
||||
svc.Children[new YamlScalarNode("ports")] = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{ctx.HostHttpPort}:80")
|
||||
);
|
||||
|
||||
// Named volumes (CIFS-backed) + theme bind mount
|
||||
svc.Children[new YamlScalarNode("volumes")] = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{a}-cms-custom:/var/www/cms/custom"),
|
||||
new YamlScalarNode($"{a}-cms-backup:/var/www/backup"),
|
||||
new YamlScalarNode($"{ctx.ThemeHostPath}:/var/www/cms/web/theme/custom"),
|
||||
new YamlScalarNode($"{a}-cms-library:/var/www/cms/library"),
|
||||
new YamlScalarNode($"{a}-cms-userscripts:/var/www/cms/web/userscripts"),
|
||||
new YamlScalarNode($"{a}-cms-ca-certs:/var/www/cms/ca-certs")
|
||||
);
|
||||
|
||||
// Secrets
|
||||
svc.Children[new YamlScalarNode("secrets")] = new YamlSequenceNode(
|
||||
ctx.SecretNames.Select(n => (YamlNode)new YamlScalarNode(n)).ToList()
|
||||
);
|
||||
|
||||
// Network
|
||||
var netNode = new YamlMappingNode();
|
||||
var aliasNode = new YamlMappingNode();
|
||||
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("web"));
|
||||
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
|
||||
svc.Children[new YamlScalarNode("networks")] = netNode;
|
||||
|
||||
// Deploy
|
||||
var deploy = BuildDeploy(ctx, memoryLimit: "1G");
|
||||
svc.Children[new YamlScalarNode("deploy")] = deploy;
|
||||
}
|
||||
|
||||
private void EnsureMemcached(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var a = ctx.CustomerAbbrev;
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{a}-memcached")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Memcached);
|
||||
svc.Children[new YamlScalarNode("command")] = new YamlSequenceNode(
|
||||
new YamlScalarNode("memcached"), new YamlScalarNode("-m"), new YamlScalarNode("15")
|
||||
);
|
||||
|
||||
var netNode = new YamlMappingNode();
|
||||
var aliasNode = new YamlMappingNode();
|
||||
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("memcached"));
|
||||
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
|
||||
svc.Children[new YamlScalarNode("networks")] = netNode;
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx, memoryLimit: "100M");
|
||||
}
|
||||
|
||||
private void EnsureQuickChart(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var a = ctx.CustomerAbbrev;
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{a}-quickchart")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.QuickChart);
|
||||
|
||||
var netNode = new YamlMappingNode();
|
||||
var aliasNode = new YamlMappingNode();
|
||||
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("quickchart"));
|
||||
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
|
||||
svc.Children[new YamlScalarNode("networks")] = netNode;
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx);
|
||||
}
|
||||
|
||||
private void EnsureNewt(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var a = ctx.CustomerAbbrev;
|
||||
// Only add newt if the template env provides the newt config
|
||||
if (!ctx.TemplateEnvValues.ContainsKey("NEWT_ID") && !ctx.TemplateEnvValues.ContainsKey("PANGOLIN_ENDPOINT"))
|
||||
return;
|
||||
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{a}-newt")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode("fosrl/newt");
|
||||
|
||||
var env = new YamlMappingNode();
|
||||
if (ctx.TemplateEnvValues.TryGetValue("PANGOLIN_ENDPOINT", out var endpoint))
|
||||
env.Children[new YamlScalarNode("PANGOLIN_ENDPOINT")] = new YamlScalarNode(endpoint);
|
||||
if (ctx.TemplateEnvValues.TryGetValue("NEWT_ID", out var newtId))
|
||||
env.Children[new YamlScalarNode("NEWT_ID")] = new YamlScalarNode(newtId);
|
||||
if (ctx.TemplateEnvValues.TryGetValue("NEWT_SECRET", out var newtSecret))
|
||||
env.Children[new YamlScalarNode("NEWT_SECRET")] = new YamlScalarNode(newtSecret);
|
||||
svc.Children[new YamlScalarNode("environment")] = env;
|
||||
|
||||
var netNode = new YamlMappingNode();
|
||||
netNode.Children[new YamlScalarNode($"{a}-net")] = new YamlMappingNode();
|
||||
svc.Children[new YamlScalarNode("networks")] = netNode;
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the environment mapping for cms-web: start with template.env values,
|
||||
/// then apply all required orchestrator overrides.
|
||||
/// </summary>
|
||||
private YamlMappingNode BuildEnvFromTemplate(Dictionary<string, string> templateEnv, RenderContext ctx)
|
||||
{
|
||||
var env = new YamlMappingNode();
|
||||
|
||||
// Apply template values first
|
||||
foreach (var (k, v) in templateEnv)
|
||||
env.Children[new YamlScalarNode(k)] = new YamlScalarNode(v);
|
||||
|
||||
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(ctx.CustomerAbbrev);
|
||||
|
||||
// Required overrides — these always win over template values
|
||||
env.Children[new YamlScalarNode("CMS_USE_MEMCACHED")] = new YamlScalarNode("true");
|
||||
env.Children[new YamlScalarNode("MEMCACHED_HOST")] = new YamlScalarNode("memcached");
|
||||
env.Children[new YamlScalarNode("CMS_SERVER_NAME")] = new YamlScalarNode(ctx.CmsServerName);
|
||||
env.Children[new YamlScalarNode("MYSQL_HOST")] = new YamlScalarNode(ctx.MySqlHost);
|
||||
env.Children[new YamlScalarNode("MYSQL_PORT")] = new YamlScalarNode(ctx.MySqlPort.ToString());
|
||||
env.Children[new YamlScalarNode("MYSQL_DATABASE")] = new YamlScalarNode(ctx.MySqlDatabase);
|
||||
env.Children[new YamlScalarNode("MYSQL_USER")] = new YamlScalarNode(ctx.MySqlUser);
|
||||
env.Children[new YamlScalarNode("MYSQL_PASSWORD")] = new YamlScalarNode($"/run/secrets/{mysqlSecretName}");
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_SERVER")] = new YamlScalarNode(ctx.SmtpServer);
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_USERNAME")] = new YamlScalarNode(ctx.SmtpUsername);
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_PASSWORD")] = new YamlScalarNode(ctx.SmtpPassword);
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_USE_TLS")] = new YamlScalarNode("YES");
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_USE_STARTTLS")] = new YamlScalarNode("YES");
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_REWRITE_DOMAIN")] = new YamlScalarNode(ctx.SmtpRewriteDomain);
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_FROM_LINE_OVERRIDE")] = new YamlScalarNode("NO");
|
||||
env.Children[new YamlScalarNode("CMS_PHP_POST_MAX_SIZE")] = new YamlScalarNode("10G");
|
||||
env.Children[new YamlScalarNode("CMS_PHP_UPLOAD_MAX_FILESIZE")] = new YamlScalarNode("10G");
|
||||
env.Children[new YamlScalarNode("CMS_PHP_MAX_EXECUTION_TIME")] = new YamlScalarNode("600");
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
private static YamlMappingNode BuildDeploy(RenderContext ctx, string? memoryLimit = null)
|
||||
{
|
||||
var deploy = new YamlMappingNode();
|
||||
|
||||
var restartPolicy = new YamlMappingNode();
|
||||
restartPolicy.Children[new YamlScalarNode("condition")] = new YamlScalarNode("any");
|
||||
deploy.Children[new YamlScalarNode("restart_policy")] = restartPolicy;
|
||||
|
||||
if (memoryLimit != null)
|
||||
{
|
||||
var resources = new YamlMappingNode();
|
||||
var limits = new YamlMappingNode();
|
||||
limits.Children[new YamlScalarNode("memory")] = new YamlScalarNode(memoryLimit);
|
||||
resources.Children[new YamlScalarNode("limits")] = limits;
|
||||
deploy.Children[new YamlScalarNode("resources")] = resources;
|
||||
}
|
||||
|
||||
if (ctx.Constraints != null && ctx.Constraints.Count > 0)
|
||||
{
|
||||
var placement = new YamlMappingNode();
|
||||
placement.Children[new YamlScalarNode("constraints")] = new YamlSequenceNode(
|
||||
ctx.Constraints.Select(c => (YamlNode)new YamlScalarNode(c)).ToList()
|
||||
);
|
||||
deploy.Children[new YamlScalarNode("placement")] = placement;
|
||||
}
|
||||
|
||||
return deploy;
|
||||
}
|
||||
|
||||
private void EnsureVolumes(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var a = ctx.CustomerAbbrev;
|
||||
var volumesKey = new YamlScalarNode("volumes");
|
||||
var volumes = new YamlMappingNode();
|
||||
root.Children[volumesKey] = volumes;
|
||||
|
||||
// CIFS-backed named volumes
|
||||
var cifsVolumes = new[] { "cms-custom", "cms-backup", "cms-library", "cms-userscripts", "cms-ca-certs" };
|
||||
foreach (var vol in cifsVolumes)
|
||||
{
|
||||
var volName = $"{a}-{vol}";
|
||||
volumes.Children[new YamlScalarNode(volName)] = BuildCifsVolumeNode(vol, ctx);
|
||||
}
|
||||
|
||||
// Plain local volume for DB (not CIFS — stays on the node)
|
||||
volumes.Children[new YamlScalarNode($"{a}-db-data")] = new YamlMappingNode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a CIFS-driver volume node matching the pattern:
|
||||
/// <code>
|
||||
/// driver: local
|
||||
/// driver_opts:
|
||||
/// type: cifs
|
||||
/// device: "//fileserver.local/share/subpath"
|
||||
/// o: "addr=fileserver.local,credentials=/etc/docker-cifs-credentials,file_mode=0660,dir_mode=0770,vers=3.0"
|
||||
/// </code>
|
||||
/// The credentials= path points to a credentials file pre-deployed on the target host.
|
||||
/// </summary>
|
||||
private YamlMappingNode BuildCifsVolumeNode(string subPath, RenderContext ctx)
|
||||
{
|
||||
var node = new YamlMappingNode();
|
||||
node.Children[new YamlScalarNode("driver")] = new YamlScalarNode("local");
|
||||
|
||||
var opts = new YamlMappingNode();
|
||||
opts.Children[new YamlScalarNode("type")] = new YamlScalarNode("cifs");
|
||||
// Append the sub-path derived from the abbreviation to the base CIFS device
|
||||
var device = _cifsOptions.Device.TrimEnd('/') + "/" + ctx.CustomerAbbrev + "-" + subPath;
|
||||
opts.Children[new YamlScalarNode("device")] = new YamlScalarNode(device);
|
||||
// The credentials file path on the remote host — written ephemerally during provisioning
|
||||
var oValue = $"addr={_cifsOptions.ServerAddr},credentials={ctx.CifsCredentialsFilePath},{_cifsOptions.MountOptions}";
|
||||
opts.Children[new YamlScalarNode("o")] = new YamlScalarNode(oValue);
|
||||
|
||||
node.Children[new YamlScalarNode("driver_opts")] = opts;
|
||||
return node;
|
||||
}
|
||||
|
||||
private void EnsureSecrets(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var secretsKey = new YamlScalarNode("secrets");
|
||||
var secrets = new YamlMappingNode();
|
||||
root.Children[secretsKey] = secrets;
|
||||
|
||||
foreach (var secretName in ctx.SecretNames)
|
||||
{
|
||||
secrets.Children[new YamlScalarNode(secretName)] = new YamlMappingNode
|
||||
{
|
||||
{ "external", "true" }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureNetworks(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var networksKey = new YamlScalarNode("networks");
|
||||
var networks = new YamlMappingNode();
|
||||
root.Children[networksKey] = networks;
|
||||
|
||||
var netDef = new YamlMappingNode();
|
||||
netDef.Children[new YamlScalarNode("driver")] = new YamlScalarNode("overlay");
|
||||
netDef.Children[new YamlScalarNode("attachable")] = new YamlScalarNode("false");
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = netDef;
|
||||
}
|
||||
|
||||
private static int GetServiceCount(YamlMappingNode root)
|
||||
{
|
||||
var servicesKey = new YamlScalarNode("services");
|
||||
if (root.Children.ContainsKey(servicesKey) && root.Children[servicesKey] is YamlMappingNode svc)
|
||||
return svc.Children.Count;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All inputs needed to render a single Compose file.
|
||||
/// </summary>
|
||||
public class RenderContext
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
/// <summary>3-letter abbreviation used as naming prefix (e.g. "ots").</summary>
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
public string StackName { get; set; } = string.Empty;
|
||||
public string CmsServerName { get; set; } = string.Empty;
|
||||
public int HostHttpPort { get; set; }
|
||||
public string ThemeHostPath { get; set; } = string.Empty;
|
||||
|
||||
// MySQL coordinates (external server — NOT a sidecar container)
|
||||
public string MySqlHost { get; set; } = string.Empty;
|
||||
public int MySqlPort { get; set; } = 3306;
|
||||
public string MySqlDatabase { get; set; } = string.Empty;
|
||||
public string MySqlUser { get; set; } = string.Empty;
|
||||
|
||||
// SMTP
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
public string SmtpUsername { get; set; } = string.Empty;
|
||||
public string SmtpPassword { get; set; } = string.Empty;
|
||||
public string SmtpRewriteDomain { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Path on the remote target host where the CIFS credentials file will be placed ephemerally during provisioning.
|
||||
/// Written as the credentials= value in volume driver_opts.
|
||||
/// </summary>
|
||||
public string CifsCredentialsFilePath { get; set; } = "/etc/docker-cifs-credentials";
|
||||
|
||||
public string TemplateYaml { get; set; } = string.Empty;
|
||||
/// <summary>Parsed key/value pairs from template.env (placeholder-substituted).</summary>
|
||||
public Dictionary<string, string> TemplateEnvValues { get; set; } = new();
|
||||
|
||||
public List<string> Constraints { get; set; } = new();
|
||||
/// <summary>Secret names to declare as external in the compose file.</summary>
|
||||
public List<string> SecretNames { get; set; } = new();
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
|
||||
/// Coordinates GitTemplateService, ComposeRenderService, ComposeValidationService,
|
||||
/// DockerCliService, DockerSecretsService, MySqlProvisionService, and XiboApiService.
|
||||
/// </summary>
|
||||
public class InstanceService
|
||||
{
|
||||
private readonly XiboContext _db;
|
||||
private readonly GitTemplateService _git;
|
||||
private readonly ComposeRenderService _compose;
|
||||
private readonly ComposeValidationService _validation;
|
||||
private readonly DockerCliService _docker;
|
||||
private readonly DockerSecretsService _secrets;
|
||||
private readonly MySqlProvisionService _mysql;
|
||||
private readonly XiboApiService _xibo;
|
||||
private readonly DockerOptions _dockerOptions;
|
||||
private readonly MySqlAdminOptions _mysqlAdminOptions;
|
||||
private readonly CifsOptions _cifsOptions;
|
||||
private readonly InstanceDefaultsOptions _instanceDefaults;
|
||||
private readonly ILogger<InstanceService> _logger;
|
||||
|
||||
public InstanceService(
|
||||
XiboContext db,
|
||||
GitTemplateService git,
|
||||
ComposeRenderService compose,
|
||||
ComposeValidationService validation,
|
||||
DockerCliService docker,
|
||||
DockerSecretsService secrets,
|
||||
MySqlProvisionService mysql,
|
||||
XiboApiService xibo,
|
||||
IOptions<DockerOptions> dockerOptions,
|
||||
IOptions<MySqlAdminOptions> mysqlAdminOptions,
|
||||
IOptions<CifsOptions> cifsOptions,
|
||||
IOptions<InstanceDefaultsOptions> instanceDefaults,
|
||||
ILogger<InstanceService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_git = git;
|
||||
_compose = compose;
|
||||
_validation = validation;
|
||||
_docker = docker;
|
||||
_secrets = secrets;
|
||||
_mysql = mysql;
|
||||
_xibo = xibo;
|
||||
_dockerOptions = dockerOptions.Value;
|
||||
_mysqlAdminOptions = mysqlAdminOptions.Value;
|
||||
_cifsOptions = cifsOptions.Value;
|
||||
_instanceDefaults = instanceDefaults.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create and deploy a new CMS instance.
|
||||
/// Steps:
|
||||
/// 1. Validate abbreviation and uniqueness
|
||||
/// 2. Fetch templates from Git (configured repo)
|
||||
/// 3. Generate secrets in memory, create on Swarm — never persisted
|
||||
/// 4. Provision MySQL DB + user via direct connection
|
||||
/// 5. Render merged single-file Compose YAML
|
||||
/// 6. Validate Compose
|
||||
/// 7. Deploy stack
|
||||
/// 8. Persist instance metadata (no secret values stored)
|
||||
/// </summary>
|
||||
public async Task<DeploymentResultDto> CreateInstanceAsync(CreateInstanceDto dto, string? userId = null, string? ipAddress = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var opLog = StartOperation(OperationType.Create, userId, ipAddress);
|
||||
|
||||
// Resolve abbreviation to lowercase for naming, uppercase for display
|
||||
var abbrev = dto.CustomerAbbrev.ToLowerInvariant();
|
||||
|
||||
// Derive stack name and resource names from abbreviation
|
||||
var stackName = abbrev;
|
||||
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(abbrev);
|
||||
|
||||
// Resolve per-instance settings (use DTO override or fall back to global defaults)
|
||||
var templateRepoUrl = !string.IsNullOrWhiteSpace(dto.TemplateRepoUrl)
|
||||
? dto.TemplateRepoUrl
|
||||
: _instanceDefaults.TemplateRepoUrl;
|
||||
var templateRepoPat = dto.TemplateRepoPat ?? _instanceDefaults.TemplateRepoPat;
|
||||
|
||||
var cmsServerName = _instanceDefaults.CmsServerNameTemplate.Replace("{abbrev}", abbrev);
|
||||
var themeHostPath = !string.IsNullOrWhiteSpace(dto.ThemeHostPath)
|
||||
? dto.ThemeHostPath
|
||||
: _instanceDefaults.ThemeHostPath;
|
||||
var mysqlDatabase = _instanceDefaults.MySqlDatabaseTemplate.Replace("{abbrev}", abbrev);
|
||||
var mysqlUser = _instanceDefaults.MySqlUserTemplate.Replace("{abbrev}", abbrev);
|
||||
var smtpRewriteDomain = ExtractDomain(_instanceDefaults.SmtpUsername);
|
||||
|
||||
try
|
||||
{
|
||||
// ----------------------------------------------------------------
|
||||
// Step 1 — Validate no duplicate stack/abbreviation
|
||||
// ----------------------------------------------------------------
|
||||
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
|
||||
|
||||
var existing = await _db.CmsInstances.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
|
||||
if (existing != null)
|
||||
throw new InvalidOperationException($"An instance with abbreviation '{dto.CustomerAbbrev}' (stack '{stackName}') already exists.");
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Step 2 — Clone / fetch templates from configured Git repo
|
||||
// ----------------------------------------------------------------
|
||||
if (string.IsNullOrWhiteSpace(templateRepoUrl))
|
||||
throw new InvalidOperationException("No template repo URL configured. Set InstanceDefaults:TemplateRepoUrl in settings.");
|
||||
|
||||
var template = await _git.FetchAsync(templateRepoUrl, templateRepoPat);
|
||||
|
||||
// Parse template.env into key/value dictionary; apply abbreviation placeholder substitution
|
||||
var templateEnvValues = ParseEnvLines(template.EnvLines, abbrev);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Step 3 — Generate secrets in memory; create on Swarm via Docker
|
||||
// NEVER store generated password values in DB or disk
|
||||
// ----------------------------------------------------------------
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
|
||||
var mysqlSecretResult = await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
|
||||
_logger.LogInformation("MySQL secret '{SecretName}' — created={Created}", mysqlSecretName, mysqlSecretResult.Created);
|
||||
|
||||
// Track secret metadata in DB (name + customer, NOT the value)
|
||||
await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Step 4 — Provision MySQL database and user
|
||||
// ----------------------------------------------------------------
|
||||
await _mysql.ProvisionAsync(mysqlDatabase, mysqlUser, mysqlPassword);
|
||||
// mysqlPassword goes out of scope after this method — it is not stored anywhere
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Step 5 — Build render context and render single Compose YAML
|
||||
// ----------------------------------------------------------------
|
||||
var constraints = (dto.Constraints is { Count: > 0 }) ? dto.Constraints : _dockerOptions.DefaultConstraints;
|
||||
|
||||
var hostHttpPort = _instanceDefaults.BaseHostHttpPort; // TODO: auto-increment per instance
|
||||
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = hostHttpPort,
|
||||
ThemeHostPath = themeHostPath,
|
||||
MySqlHost = _mysqlAdminOptions.Host,
|
||||
MySqlPort = _mysqlAdminOptions.Port,
|
||||
MySqlDatabase = mysqlDatabase,
|
||||
MySqlUser = mysqlUser,
|
||||
SmtpServer = _instanceDefaults.SmtpServer,
|
||||
SmtpUsername = _instanceDefaults.SmtpUsername,
|
||||
SmtpPassword = _instanceDefaults.SmtpPassword,
|
||||
SmtpRewriteDomain = smtpRewriteDomain,
|
||||
TemplateYaml = template.Yaml,
|
||||
TemplateEnvValues = templateEnvValues,
|
||||
Constraints = constraints,
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
CifsCredentialsFilePath = "/etc/docker-cifs-credentials"
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Step 6 — Validate Compose
|
||||
// ----------------------------------------------------------------
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
var validationResult = _validation.Validate(composeYaml);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
var errorMsg = string.Join("; ", validationResult.Errors);
|
||||
throw new InvalidOperationException($"Compose validation failed: {errorMsg}");
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Step 7 — Deploy stack via Docker
|
||||
// ----------------------------------------------------------------
|
||||
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Step 8 — Persist instance metadata (no secret values stored)
|
||||
// ----------------------------------------------------------------
|
||||
var instance = new CmsInstance
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = hostHttpPort,
|
||||
ThemeHostPath = themeHostPath,
|
||||
LibraryHostPath = _instanceDefaults.LibraryShareSubPath.Replace("{abbrev}", abbrev),
|
||||
SmtpServer = _instanceDefaults.SmtpServer,
|
||||
SmtpUsername = _instanceDefaults.SmtpUsername,
|
||||
Constraints = JsonSerializer.Serialize(constraints),
|
||||
TemplateRepoUrl = templateRepoUrl,
|
||||
TemplateRepoPat = templateRepoPat,
|
||||
TemplateLastFetch = template.FetchedAt,
|
||||
Status = InstanceStatus.Active,
|
||||
XiboUsername = dto.XiboUsername,
|
||||
XiboPassword = dto.XiboPassword,
|
||||
XiboApiTestStatus = XiboApiTestStatus.Unknown
|
||||
};
|
||||
|
||||
_db.CmsInstances.Add(instance);
|
||||
|
||||
sw.Stop();
|
||||
opLog.InstanceId = instance.Id;
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance deployed: {stackName}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
|
||||
stackName, instance.Id, sw.ElapsedMilliseconds);
|
||||
|
||||
deployResult.ServiceCount = 4;
|
||||
deployResult.Message = "Instance deployed successfully.";
|
||||
return deployResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
opLog.Status = OperationStatus.Failure;
|
||||
opLog.Message = $"Create failed: {ex.Message}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogError(ex, "Instance create failed: abbrev={Abbrev}", abbrev);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update and redeploy an existing CMS instance.
|
||||
/// </summary>
|
||||
public async Task<DeploymentResultDto> UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null, string? ipAddress = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var opLog = StartOperation(OperationType.Update, userId, ipAddress);
|
||||
|
||||
try
|
||||
{
|
||||
var instance = await _db.CmsInstances.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Instance {id} not found.");
|
||||
|
||||
_logger.LogInformation("Updating instance: {StackName} (id={Id})", instance.StackName, id);
|
||||
|
||||
// Apply updates
|
||||
if (dto.TemplateRepoUrl != null) instance.TemplateRepoUrl = dto.TemplateRepoUrl;
|
||||
if (dto.TemplateRepoPat != null) instance.TemplateRepoPat = dto.TemplateRepoPat;
|
||||
if (dto.SmtpServer != null) instance.SmtpServer = dto.SmtpServer;
|
||||
if (dto.SmtpUsername != null) instance.SmtpUsername = dto.SmtpUsername;
|
||||
if (dto.Constraints != null) instance.Constraints = JsonSerializer.Serialize(dto.Constraints);
|
||||
if (dto.XiboUsername != null) instance.XiboUsername = dto.XiboUsername;
|
||||
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
|
||||
|
||||
// Re-fetch templates
|
||||
var template = await _git.FetchAsync(instance.TemplateRepoUrl, instance.TemplateRepoPat, forceRefresh: true);
|
||||
instance.TemplateLastFetch = template.FetchedAt;
|
||||
|
||||
// Re-render Compose
|
||||
var constraints = string.IsNullOrEmpty(instance.Constraints)
|
||||
? _dockerOptions.DefaultConstraints
|
||||
: JsonSerializer.Deserialize<List<string>>(instance.Constraints) ?? _dockerOptions.DefaultConstraints;
|
||||
|
||||
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(instance.CustomerName);
|
||||
|
||||
var abbrev = instance.CustomerAbbrev;
|
||||
var templateEnvValues = ParseEnvLines(template.EnvLines, abbrev);
|
||||
var smtpRewriteDomain = ExtractDomain(instance.SmtpUsername);
|
||||
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = instance.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = instance.StackName,
|
||||
CmsServerName = instance.CmsServerName,
|
||||
HostHttpPort = instance.HostHttpPort,
|
||||
ThemeHostPath = instance.ThemeHostPath,
|
||||
MySqlHost = _mysqlAdminOptions.Host,
|
||||
MySqlPort = _mysqlAdminOptions.Port,
|
||||
MySqlDatabase = _instanceDefaults.MySqlDatabaseTemplate.Replace("{abbrev}", abbrev),
|
||||
MySqlUser = _instanceDefaults.MySqlUserTemplate.Replace("{abbrev}", abbrev),
|
||||
SmtpServer = instance.SmtpServer,
|
||||
SmtpUsername = instance.SmtpUsername,
|
||||
SmtpPassword = _instanceDefaults.SmtpPassword,
|
||||
SmtpRewriteDomain = smtpRewriteDomain,
|
||||
TemplateYaml = template.Yaml,
|
||||
TemplateEnvValues = templateEnvValues,
|
||||
Constraints = constraints,
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
CifsCredentialsFilePath = "/etc/docker-cifs-credentials"
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
|
||||
// Validate
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
var validationResult = _validation.Validate(composeYaml);
|
||||
if (!validationResult.IsValid)
|
||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// Redeploy
|
||||
var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");
|
||||
|
||||
instance.UpdatedAt = DateTime.UtcNow;
|
||||
instance.Status = InstanceStatus.Active;
|
||||
|
||||
sw.Stop();
|
||||
opLog.InstanceId = instance.Id;
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance updated: {instance.StackName}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Instance updated: {StackName} (id={Id}) | duration={DurationMs}ms",
|
||||
instance.StackName, id, sw.ElapsedMilliseconds);
|
||||
|
||||
deployResult.ServiceCount = 4;
|
||||
deployResult.Message = "Instance updated and redeployed.";
|
||||
return deployResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
opLog.Status = OperationStatus.Failure;
|
||||
opLog.Message = $"Update failed: {ex.Message}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogError(ex, "Instance update failed (id={Id})", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a CMS instance (soft delete in DB; removes stack from Swarm).
|
||||
/// </summary>
|
||||
public async Task<DeploymentResultDto> DeleteInstanceAsync(
|
||||
Guid id, bool retainSecrets = false, bool clearXiboCreds = true,
|
||||
string? userId = null, string? ipAddress = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var opLog = StartOperation(OperationType.Delete, userId, ipAddress);
|
||||
|
||||
try
|
||||
{
|
||||
var instance = await _db.CmsInstances.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Instance {id} not found.");
|
||||
|
||||
_logger.LogInformation("Deleting instance: {StackName} (id={Id}) retainSecrets={RetainSecrets}",
|
||||
instance.StackName, id, retainSecrets);
|
||||
|
||||
// Remove stack
|
||||
var result = await _docker.RemoveStackAsync(instance.StackName);
|
||||
|
||||
// Optionally remove secrets
|
||||
if (!retainSecrets)
|
||||
{
|
||||
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(instance.CustomerName);
|
||||
await _secrets.DeleteSecretAsync(mysqlSecretName);
|
||||
|
||||
var secretMeta = await _db.SecretMetadata
|
||||
.FirstOrDefaultAsync(s => s.Name == mysqlSecretName);
|
||||
if (secretMeta != null)
|
||||
_db.SecretMetadata.Remove(secretMeta);
|
||||
}
|
||||
|
||||
// Soft delete instance
|
||||
instance.Status = InstanceStatus.Deleted;
|
||||
instance.DeletedAt = DateTime.UtcNow;
|
||||
instance.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (clearXiboCreds)
|
||||
{
|
||||
instance.XiboUsername = null;
|
||||
instance.XiboPassword = null;
|
||||
instance.XiboApiTestStatus = XiboApiTestStatus.Unknown;
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
opLog.InstanceId = instance.Id;
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance deleted: {instance.StackName}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Instance deleted: {StackName} (id={Id}) | duration={DurationMs}ms",
|
||||
instance.StackName, id, sw.ElapsedMilliseconds);
|
||||
|
||||
result.Message = "Instance deleted.";
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
opLog.Status = OperationStatus.Failure;
|
||||
opLog.Message = $"Delete failed: {ex.Message}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogError(ex, "Instance delete failed (id={Id})", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get an instance by ID.
|
||||
/// </summary>
|
||||
public async Task<CmsInstance?> GetInstanceAsync(Guid id)
|
||||
{
|
||||
return await _db.CmsInstances.FindAsync(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all active instances with optional filter.
|
||||
/// </summary>
|
||||
public async Task<(List<CmsInstance> Items, int TotalCount)> ListInstancesAsync(
|
||||
int page = 1, int pageSize = 50, string? filter = null)
|
||||
{
|
||||
var query = _db.CmsInstances.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
query = query.Where(i =>
|
||||
i.CustomerName.Contains(filter) ||
|
||||
i.StackName.Contains(filter));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test the Xibo API connection for an instance.
|
||||
/// </summary>
|
||||
public async Task<XiboTestResult> TestXiboConnectionAsync(Guid id)
|
||||
{
|
||||
var instance = await _db.CmsInstances.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Instance {id} not found.");
|
||||
|
||||
if (string.IsNullOrEmpty(instance.XiboUsername) || string.IsNullOrEmpty(instance.XiboPassword))
|
||||
return new XiboTestResult { IsValid = false, Message = "No Xibo credentials stored." };
|
||||
|
||||
var url = $"http://localhost:{instance.HostHttpPort}";
|
||||
var result = await _xibo.TestConnectionAsync(url, instance.XiboUsername, instance.XiboPassword);
|
||||
|
||||
instance.XiboApiTestStatus = result.IsValid ? XiboApiTestStatus.Success : XiboApiTestStatus.Failed;
|
||||
instance.XiboApiTestedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task EnsureSecretMetadata(string name, bool isGlobal, string? customerName)
|
||||
{
|
||||
var existing = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == name);
|
||||
if (existing == null)
|
||||
{
|
||||
_db.SecretMetadata.Add(new SecretMetadata
|
||||
{
|
||||
Name = name,
|
||||
IsGlobal = isGlobal,
|
||||
CustomerName = customerName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static OperationLog StartOperation(OperationType type, string? userId, string? ipAddress)
|
||||
{
|
||||
return new OperationLog
|
||||
{
|
||||
Operation = type,
|
||||
UserId = userId,
|
||||
IpAddress = ipAddress,
|
||||
Status = OperationStatus.Pending
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateRandomPassword(int length)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||
return RandomNumberGenerator.GetString(chars, length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse template.env lines into a key/value dictionary.
|
||||
/// Replaces {abbrev} placeholders with the customer abbreviation.
|
||||
/// Skips empty lines and comments.
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> ParseEnvLines(IEnumerable<string> lines, string abbrev)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
|
||||
continue;
|
||||
var eqIdx = trimmed.IndexOf('=');
|
||||
if (eqIdx <= 0) continue;
|
||||
var key = trimmed[..eqIdx].Trim();
|
||||
var value = trimmed[(eqIdx + 1)..].Trim()
|
||||
.Replace("{CUSTOMERABBREVIATION}", abbrev)
|
||||
.Replace("{CUSTOMERABBREV}", abbrev)
|
||||
.Replace("{abbrev}", abbrev);
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Extracts the domain part from an email address, e.g. "user@ots-signs.com" → "ots-signs.com".</summary>
|
||||
private static string ExtractDomain(string email)
|
||||
{
|
||||
var atIdx = email?.IndexOf('@') ?? -1;
|
||||
return atIdx >= 0 ? email![(atIdx + 1)..] : string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" }
|
||||
]
|
||||
},
|
||||
"FileLogging": {
|
||||
"Enabled": true,
|
||||
"Path": "/var/log/xibo-admin",
|
||||
"RollingInterval": "Day",
|
||||
"RetentionDays": 30,
|
||||
"FileSizeLimitBytes": 104857600
|
||||
},
|
||||
"Authentication": {
|
||||
"LocalAdminToken": ""
|
||||
},
|
||||
"Git": {
|
||||
"CacheDir": "/var/cache/xibo-admin-templates",
|
||||
"CacheTtlMinutes": 60,
|
||||
"ShallowCloneDepth": 1
|
||||
},
|
||||
"Docker": {
|
||||
"SocketPath": "unix:///var/run/docker.sock",
|
||||
"DefaultConstraints": [ "node.labels.xibo==true" ],
|
||||
"DeployTimeoutSeconds": 30,
|
||||
"ValidateBeforeDeploy": true
|
||||
},
|
||||
"Xibo": {
|
||||
"DefaultImages": {
|
||||
"Cms": "ghcr.io/xibosignage/xibo-cms:release-4.4.0",
|
||||
"Mysql": "mysql:8.4",
|
||||
"Memcached": "memcached:alpine",
|
||||
"QuickChart": "ianw/quickchart"
|
||||
},
|
||||
"TestConnectionTimeoutSeconds": 10
|
||||
},
|
||||
"Database": {
|
||||
"Provider": "Sqlite"
|
||||
},
|
||||
"MySqlAdmin": {
|
||||
"Host": "cms-sql.otshosting.app",
|
||||
"Port": 3306,
|
||||
"AdminUser": "root",
|
||||
"AdminPassword": "",
|
||||
"AllowInsecureTls": false
|
||||
},
|
||||
"Cifs": {
|
||||
"Device": "//fileserver.local/xibo-data",
|
||||
"ServerAddr": "fileserver.local",
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"MountOptions": "vers=3.0,file_mode=0660,dir_mode=0770"
|
||||
},
|
||||
"InstanceDefaults": {
|
||||
"TemplateRepoUrl": "",
|
||||
"TemplateRepoPat": null,
|
||||
"CmsServerNameTemplate": "{abbrev}.ots-signs.com",
|
||||
"SmtpServer": "smtp.azurecomm.net:587",
|
||||
"SmtpUsername": "",
|
||||
"SmtpPassword": "",
|
||||
"BaseHostHttpPort": 8080,
|
||||
"ThemeHostPath": "/cms/ots-theme",
|
||||
"LibraryShareSubPath": "{abbrev}-cms-library",
|
||||
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
|
||||
"MySqlUserTemplate": "{abbrev}_cms"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Default": "Data Source=xibo-admin.db"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
125
template.yml
Normal file
125
template.yml
Normal file
@@ -0,0 +1,125 @@
|
||||
# Customer: {{CUSTOMER_NAME}}
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
|
||||
{{ABBREV}}-web:
|
||||
image: {{CMS_IMAGE}}
|
||||
environment:
|
||||
CMS_USE_MEMCACHED: "true"
|
||||
MEMCACHED_HOST: memcached
|
||||
MYSQL_HOST: {{MYSQL_HOST}}
|
||||
MYSQL_PORT: "{{MYSQL_PORT}}"
|
||||
MYSQL_DATABASE: {{MYSQL_DATABASE}}
|
||||
MYSQL_USER: {{MYSQL_USER}}
|
||||
MYSQL_PASSWORD_FILE: /run/secrets/{{ABBREV}}-cms-db-password
|
||||
CMS_SERVER_NAME: {{CMS_SERVER_NAME}}
|
||||
CMS_SMTP_SERVER: {{SMTP_SERVER}}
|
||||
CMS_SMTP_USERNAME: {{SMTP_USERNAME}}
|
||||
CMS_SMTP_PASSWORD: {{SMTP_PASSWORD}}
|
||||
CMS_SMTP_USE_TLS: {{SMTP_USE_TLS}}
|
||||
CMS_SMTP_USE_STARTTLS: {{SMTP_USE_STARTTLS}}
|
||||
CMS_SMTP_REWRITE_DOMAIN: {{SMTP_REWRITE_DOMAIN}}
|
||||
CMS_SMTP_HOSTNAME: {{SMTP_HOSTNAME}}
|
||||
CMS_SMTP_FROM_LINE_OVERRIDE: {{SMTP_FROM_LINE_OVERRIDE}}
|
||||
CMS_PHP_POST_MAX_SIZE: {{PHP_POST_MAX_SIZE}}
|
||||
CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}}
|
||||
CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}"
|
||||
secrets:
|
||||
- {{ABBREV}}-cms-db-password
|
||||
volumes:
|
||||
- {{ABBREV}}-cms-custom:/var/www/cms/custom
|
||||
- {{ABBREV}}-cms-backup:/var/www/backup
|
||||
- {{THEME_HOST_PATH}}:/var/www/cms/web/theme/custom
|
||||
- {{ABBREV}}-cms-library:/var/www/cms/library
|
||||
- {{ABBREV}}-cms-userscripts:/var/www/cms/web/userscripts
|
||||
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
|
||||
ports:
|
||||
- "{{HOST_HTTP_PORT}}:80"
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
aliases:
|
||||
- web
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
|
||||
{{ABBREV}}-memcached:
|
||||
image: {{MEMCACHED_IMAGE}}
|
||||
command: [memcached, -m, "15"]
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
aliases:
|
||||
- memcached
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
resources:
|
||||
limits:
|
||||
memory: 100M
|
||||
|
||||
{{ABBREV}}-quickchart:
|
||||
image: {{QUICKCHART_IMAGE}}
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
aliases:
|
||||
- quickchart
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
|
||||
{{ABBREV}}-newt:
|
||||
image: {{NEWT_IMAGE}}
|
||||
environment:
|
||||
PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}}
|
||||
NEWT_ID: {{NEWT_ID}}
|
||||
NEWT_SECRET: {{NEWT_SECRET}}
|
||||
networks:
|
||||
{{ABBREV}}-net: {}
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
|
||||
networks:
|
||||
{{ABBREV}}-net:
|
||||
driver: overlay
|
||||
attachable: false
|
||||
|
||||
volumes:
|
||||
{{ABBREV}}-cms-custom:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-custom
|
||||
o: {{CIFS_OPTS}}
|
||||
{{ABBREV}}-cms-backup:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-backup
|
||||
o: {{CIFS_OPTS}}
|
||||
{{ABBREV}}-cms-library:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-library
|
||||
o: {{CIFS_OPTS}}
|
||||
{{ABBREV}}-cms-userscripts:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-userscripts
|
||||
o: {{CIFS_OPTS}}
|
||||
{{ABBREV}}-cms-ca-certs:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
device: //{{CIFS_SERVER}}/{{CIFS_SHARE_NAME}}/{{ABBREV}}-cms-ca-certs
|
||||
o: {{CIFS_OPTS}}
|
||||
|
||||
secrets:
|
||||
{{ABBREV}}-cms-db-password:
|
||||
external: true
|
||||
Reference in New Issue
Block a user