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)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("CifsShareBasePath")
|
b.Property<string>("CifsShareFolder")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CifsShareName")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ public class CreateInstanceDto
|
|||||||
public string? CifsServer { get; set; }
|
public string? CifsServer { get; set; }
|
||||||
|
|
||||||
[MaxLength(500)]
|
[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)]
|
[MaxLength(200)]
|
||||||
public string? CifsUsername { get; set; }
|
public string? CifsUsername { get; set; }
|
||||||
|
|||||||
@@ -3,6 +3,5 @@ namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
|||||||
public class TemplateConfig
|
public class TemplateConfig
|
||||||
{
|
{
|
||||||
public string Yaml { get; set; } = string.Empty;
|
public string Yaml { get; set; } = string.Empty;
|
||||||
public List<string> EnvLines { get; set; } = new();
|
|
||||||
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ public class UpdateInstanceDto
|
|||||||
public string? CifsServer { get; set; }
|
public string? CifsServer { get; set; }
|
||||||
|
|
||||||
[MaxLength(500)]
|
[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)]
|
[MaxLength(200)]
|
||||||
public string? CifsUsername { get; set; }
|
public string? CifsUsername { get; set; }
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ public class CmsInstance
|
|||||||
public string? CifsServer { get; set; }
|
public string? CifsServer { get; set; }
|
||||||
|
|
||||||
[MaxLength(500)]
|
[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)]
|
[MaxLength(200)]
|
||||||
public string? CifsUsername { get; set; }
|
public string? CifsUsername { get; set; }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" 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="Microsoft.Extensions.Options" Version="9.0.2" />
|
||||||
|
<PackageReference Include="MySqlConnector" Version="2.5.0" />
|
||||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
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;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a Docker Compose v3.9 YAML for a Xibo CMS stack.
|
/// Renders a Docker Compose file by loading a template from the git repo and substituting
|
||||||
/// Combined format: no separate config.env, no MySQL container (external DB),
|
/// all {{PLACEHOLDER}} tokens with values from RenderContext.
|
||||||
/// CIFS volumes, Newt tunnel service, and inline environment variables.
|
/// 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>
|
/// </summary>
|
||||||
public class ComposeRenderService
|
public class ComposeRenderService
|
||||||
{
|
{
|
||||||
@@ -22,278 +17,220 @@ public class ComposeRenderService
|
|||||||
_logger = logger;
|
_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
|
var cifsOpts = BuildCifsOpts(ctx);
|
||||||
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
|
|
||||||
|
|
||||||
// Comment — customer name (added as a YAML comment isn't natively supported,
|
return templateYaml
|
||||||
// so we prepend it manually after serialization)
|
.Replace("{{ABBREV}}", ctx.CustomerAbbrev)
|
||||||
BuildServices(root, ctx);
|
.Replace("{{CUSTOMER_NAME}}", ctx.CustomerName)
|
||||||
BuildNetworks(root, ctx);
|
.Replace("{{STACK_NAME}}", ctx.StackName)
|
||||||
BuildVolumes(root, ctx);
|
.Replace("{{CMS_SERVER_NAME}}", ctx.CmsServerName)
|
||||||
BuildSecrets(root, ctx);
|
.Replace("{{HOST_HTTP_PORT}}", ctx.HostHttpPort.ToString())
|
||||||
|
.Replace("{{CMS_IMAGE}}", ctx.CmsImage)
|
||||||
var doc = new YamlDocument(root);
|
.Replace("{{MEMCACHED_IMAGE}}", ctx.MemcachedImage)
|
||||||
var stream = new YamlStream(doc);
|
.Replace("{{QUICKCHART_IMAGE}}", ctx.QuickChartImage)
|
||||||
|
.Replace("{{NEWT_IMAGE}}", ctx.NewtImage)
|
||||||
using var writer = new StringWriter();
|
.Replace("{{THEME_HOST_PATH}}", ctx.ThemeHostPath)
|
||||||
stream.Save(writer, assignAnchors: false);
|
.Replace("{{MYSQL_HOST}}", ctx.MySqlHost)
|
||||||
var output = writer.ToString()
|
.Replace("{{MYSQL_PORT}}", ctx.MySqlPort)
|
||||||
.Replace("...\n", "").Replace("...", "");
|
.Replace("{{MYSQL_DATABASE}}", ctx.MySqlDatabase)
|
||||||
|
.Replace("{{MYSQL_USER}}", ctx.MySqlUser)
|
||||||
// Prepend customer name comment
|
.Replace("{{SMTP_SERVER}}", ctx.SmtpServer)
|
||||||
output = $"# Customer: {ctx.CustomerName}\n{output}";
|
.Replace("{{SMTP_USERNAME}}", ctx.SmtpUsername)
|
||||||
|
.Replace("{{SMTP_PASSWORD}}", ctx.SmtpPassword)
|
||||||
_logger.LogDebug("Compose rendered for {StackName}: {ServiceCount} services",
|
.Replace("{{SMTP_USE_TLS}}", ctx.SmtpUseTls)
|
||||||
ctx.StackName, 4);
|
.Replace("{{SMTP_USE_STARTTLS}}", ctx.SmtpUseStartTls)
|
||||||
|
.Replace("{{SMTP_REWRITE_DOMAIN}}", ctx.SmtpRewriteDomain)
|
||||||
return output;
|
.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 static string BuildCifsOpts(RenderContext ctx)
|
||||||
|
|
||||||
private void BuildServices(YamlMappingNode root, RenderContext ctx)
|
|
||||||
{
|
{
|
||||||
var services = new YamlMappingNode();
|
if (string.IsNullOrWhiteSpace(ctx.CifsServer))
|
||||||
root.Children[new YamlScalarNode("services")] = services;
|
return string.Empty;
|
||||||
|
|
||||||
BuildWebService(services, ctx);
|
// vers=3.0 is required by most modern SMB servers (e.g. Hetzner Storage Box).
|
||||||
BuildMemcachedService(services, ctx);
|
// Without it, mount.cifs may negotiate SMBv1/2.1 which gets rejected as "permission denied".
|
||||||
BuildQuickChartService(services, ctx);
|
var opts = $"addr={ctx.CifsServer},username={ctx.CifsUsername},password={ctx.CifsPassword},vers=3.0";
|
||||||
|
|
||||||
if (ctx.IncludeNewt)
|
|
||||||
BuildNewtService(services, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildWebService(YamlMappingNode services, RenderContext ctx)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildMemcachedService(YamlMappingNode services, RenderContext ctx)
|
|
||||||
{
|
|
||||||
var svc = new YamlMappingNode();
|
|
||||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-memcached")] = svc;
|
|
||||||
|
|
||||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.MemcachedImage);
|
|
||||||
|
|
||||||
var command = new YamlSequenceNode(
|
|
||||||
new YamlScalarNode("memcached"),
|
|
||||||
new YamlScalarNode("-m"),
|
|
||||||
new YamlScalarNode("15")
|
|
||||||
);
|
|
||||||
svc.Children[new YamlScalarNode("command")] = command;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
|
|
||||||
{
|
|
||||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
|
|
||||||
{ "resources", new YamlMappingNode
|
|
||||||
{ { "limits", new YamlMappingNode { { "memory", "100M" } } } }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildQuickChartService(YamlMappingNode services, RenderContext ctx)
|
|
||||||
{
|
|
||||||
var svc = new YamlMappingNode();
|
|
||||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-quickchart")] = svc;
|
|
||||||
|
|
||||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.QuickChartImage);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
|
|
||||||
{
|
|
||||||
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildNewtService(YamlMappingNode services, RenderContext ctx)
|
|
||||||
{
|
|
||||||
var svc = new YamlMappingNode();
|
|
||||||
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-newt")] = svc;
|
|
||||||
|
|
||||||
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))
|
if (!string.IsNullOrWhiteSpace(ctx.CifsExtraOptions))
|
||||||
opts += $",{ctx.CifsExtraOptions}";
|
opts += $",{ctx.CifsExtraOptions}";
|
||||||
|
return opts;
|
||||||
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 ─────────────────────────────────────────────────────────────
|
/// <summary>
|
||||||
|
/// Combines share name and optional subfolder into a single path segment.
|
||||||
private void BuildSecrets(YamlMappingNode root, RenderContext ctx)
|
/// 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 secrets = new YamlMappingNode();
|
var name = (shareName ?? string.Empty).Trim('/');
|
||||||
root.Children[new YamlScalarNode("secrets")] = secrets;
|
var folder = (shareFolder ?? string.Empty).Trim('/');
|
||||||
|
return string.IsNullOrEmpty(folder) ? name : $"{name}/{folder}";
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var secretName in ctx.SecretNames)
|
/// <summary>
|
||||||
{
|
/// Returns the canonical <c>template.yml</c> content with all placeholders.
|
||||||
secrets.Children[new YamlScalarNode(secretName)] =
|
/// Commit this file to the root of your template git repository.
|
||||||
new YamlMappingNode { { "external", "true" } };
|
/// </summary>
|
||||||
}
|
public static string GetTemplateYaml() => TemplateYaml;
|
||||||
}
|
|
||||||
|
// ── Canonical template ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public const string TemplateYaml =
|
||||||
|
"""
|
||||||
|
# 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
|
||||||
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Context object with all inputs needed to render a Compose file.</summary>
|
/// <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";
|
public string PhpMaxExecutionTime { get; set; } = "600";
|
||||||
|
|
||||||
// Pangolin / Newt
|
// Pangolin / Newt
|
||||||
public bool IncludeNewt { get; set; } = true;
|
|
||||||
public string PangolinEndpoint { get; set; } = "https://app.pangolin.net";
|
public string PangolinEndpoint { get; set; } = "https://app.pangolin.net";
|
||||||
public string? NewtId { get; set; }
|
public string? NewtId { get; set; }
|
||||||
public string? NewtSecret { get; set; }
|
public string? NewtSecret { get; set; }
|
||||||
|
|
||||||
// CIFS volume settings
|
// CIFS volume settings
|
||||||
public bool UseCifsVolumes { get; set; }
|
|
||||||
public string? CifsServer { 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? CifsUsername { get; set; }
|
||||||
public string? CifsPassword { get; set; }
|
public string? CifsPassword { get; set; }
|
||||||
public string? CifsExtraOptions { 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 yamlPath = FindFile(cacheDir, "template.yml");
|
||||||
var envPath = FindFile(cacheDir, "template.env");
|
|
||||||
|
|
||||||
if (yamlPath == null)
|
if (yamlPath == null)
|
||||||
throw new FileNotFoundException("template.yml 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.");
|
||||||
if (envPath == null)
|
|
||||||
throw new FileNotFoundException("template.env not found in repository root.");
|
|
||||||
|
|
||||||
var yaml = await File.ReadAllTextAsync(yamlPath);
|
var yaml = await File.ReadAllTextAsync(yamlPath);
|
||||||
var envLines = (await File.ReadAllLinesAsync(envPath))
|
|
||||||
.Where(l => !string.IsNullOrWhiteSpace(l) && !l.TrimStart().StartsWith('#'))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new TemplateConfig
|
return new TemplateConfig
|
||||||
{
|
{
|
||||||
Yaml = yaml,
|
Yaml = yaml,
|
||||||
EnvLines = envLines,
|
|
||||||
FetchedAt = DateTime.UtcNow
|
FetchedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,30 @@ public interface IDockerCliService
|
|||||||
Task<DeploymentResultDto> RemoveStackAsync(string stackName);
|
Task<DeploymentResultDto> RemoveStackAsync(string stackName);
|
||||||
Task<List<StackInfo>> ListStacksAsync();
|
Task<List<StackInfo>> ListStacksAsync();
|
||||||
Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName);
|
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
|
public class StackInfo
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using MySqlConnector;
|
||||||
using OTSSignsOrchestrator.Core.Configuration;
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
using OTSSignsOrchestrator.Core.Data;
|
using OTSSignsOrchestrator.Core.Data;
|
||||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
@@ -72,21 +73,33 @@ public class InstanceService
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
|
_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()
|
var existing = await _db.CmsInstances.IgnoreQueryFilters()
|
||||||
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
|
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
|
||||||
if (existing != 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 repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
|
||||||
var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat);
|
var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(repoUrl))
|
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||||
{
|
throw new InvalidOperationException("Git template repository URL is not configured. Set it in Settings → Git Repo URL.");
|
||||||
_logger.LogInformation("Cloning template repo: {RepoUrl}", repoUrl);
|
|
||||||
await _git.FetchAsync(repoUrl, repoPat);
|
_logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl);
|
||||||
}
|
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
|
||||||
|
|
||||||
// ── 2. Generate MySQL password → Docker Swarm secret ────────────
|
// ── 2. Generate MySQL password → Docker Swarm secret ────────────
|
||||||
var mysqlPassword = GenerateRandomPassword(32);
|
var mysqlPassword = GenerateRandomPassword(32);
|
||||||
@@ -116,7 +129,8 @@ public class InstanceService
|
|||||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||||
|
|
||||||
var cifsServer = dto.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
|
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 cifsUsername = dto.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
|
||||||
var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
|
var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
|
||||||
var cifsOptions = dto.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
var 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 phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||||
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||||
|
|
||||||
// ── 4. Render compose YAML ──────────────────────────────────────
|
// ── 4. Render compose YAML from template ────────────────────────
|
||||||
var renderCtx = new RenderContext
|
var renderCtx = new RenderContext
|
||||||
{
|
{
|
||||||
CustomerName = dto.CustomerName,
|
CustomerName = dto.CustomerName,
|
||||||
@@ -158,20 +172,21 @@ public class InstanceService
|
|||||||
PhpPostMaxSize = phpPostMaxSize,
|
PhpPostMaxSize = phpPostMaxSize,
|
||||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||||
IncludeNewt = true,
|
|
||||||
PangolinEndpoint = pangolinEndpoint,
|
PangolinEndpoint = pangolinEndpoint,
|
||||||
NewtId = dto.NewtId,
|
NewtId = dto.NewtId,
|
||||||
NewtSecret = dto.NewtSecret,
|
NewtSecret = dto.NewtSecret,
|
||||||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
|
||||||
CifsServer = cifsServer,
|
CifsServer = cifsServer,
|
||||||
CifsShareBasePath = cifsShareBasePath,
|
CifsShareName = cifsShareName,
|
||||||
|
CifsShareFolder = cifsShareFolder,
|
||||||
CifsUsername = cifsUsername,
|
CifsUsername = cifsUsername,
|
||||||
CifsPassword = cifsPassword,
|
CifsPassword = cifsPassword,
|
||||||
CifsExtraOptions = cifsOptions,
|
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)
|
if (_dockerOptions.ValidateBeforeDeploy)
|
||||||
{
|
{
|
||||||
@@ -180,12 +195,35 @@ public class InstanceService
|
|||||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
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);
|
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||||||
if (!deployResult.Success)
|
if (!deployResult.Success)
|
||||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
||||||
|
|
||||||
// ── 6. Record instance ──────────────────────────────────────────
|
// ── 8. Record instance ──────────────────────────────────────────
|
||||||
var instance = new CmsInstance
|
var instance = new CmsInstance
|
||||||
{
|
{
|
||||||
CustomerName = dto.CustomerName,
|
CustomerName = dto.CustomerName,
|
||||||
@@ -202,7 +240,8 @@ public class InstanceService
|
|||||||
Status = InstanceStatus.Active,
|
Status = InstanceStatus.Active,
|
||||||
SshHostId = dto.SshHostId,
|
SshHostId = dto.SshHostId,
|
||||||
CifsServer = cifsServer,
|
CifsServer = cifsServer,
|
||||||
CifsShareBasePath = cifsShareBasePath,
|
CifsShareName = cifsShareName,
|
||||||
|
CifsShareFolder = cifsShareFolder,
|
||||||
CifsUsername = cifsUsername,
|
CifsUsername = cifsUsername,
|
||||||
CifsPassword = cifsPassword,
|
CifsPassword = cifsPassword,
|
||||||
CifsExtraOptions = cifsOptions,
|
CifsExtraOptions = cifsOptions,
|
||||||
@@ -220,7 +259,7 @@ public class InstanceService
|
|||||||
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
|
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
|
||||||
stackName, instance.Id, sw.ElapsedMilliseconds);
|
stackName, instance.Id, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
deployResult.ServiceCount = renderCtx.IncludeNewt ? 4 : 3;
|
deployResult.ServiceCount = 4;
|
||||||
deployResult.Message = "Instance deployed successfully.";
|
deployResult.Message = "Instance deployed successfully.";
|
||||||
return deployResult;
|
return deployResult;
|
||||||
}
|
}
|
||||||
@@ -238,13 +277,13 @@ public class InstanceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates MySQL database and user on external MySQL server via SSH.
|
/// Creates MySQL database and user on the external MySQL server using a direct TCP connection.
|
||||||
/// Called by the ViewModel before CreateInstanceAsync since it needs SSH access.
|
/// Admin credentials are sourced from SettingsService (MySql.AdminUser / MySql.AdminPassword).
|
||||||
|
/// The new user's password is passed in and never logged.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
|
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
|
||||||
string abbrev,
|
string abbrev,
|
||||||
string mysqlPassword,
|
string mysqlPassword)
|
||||||
Func<string, Task<(int ExitCode, string Stdout, string Stderr)>> runSshCommand)
|
|
||||||
{
|
{
|
||||||
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||||
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
|
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 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 userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
|
||||||
|
|
||||||
var safePwd = mySqlAdminPassword.Replace("'", "'\\''");
|
_logger.LogInformation("Creating MySQL database {Db} and user {User} via direct TCP", dbName, userName);
|
||||||
var safeUserPwd = mysqlPassword.Replace("'", "'\\''");
|
|
||||||
|
|
||||||
var sql = $"CREATE DATABASE IF NOT EXISTS \\`{dbName}\\`; "
|
if (!int.TryParse(mySqlPort, out var port))
|
||||||
+ $"CREATE USER IF NOT EXISTS '{userName}'@'%' IDENTIFIED BY '{safeUserPwd}'; "
|
port = 3306;
|
||||||
+ $"GRANT ALL PRIVILEGES ON \\`{dbName}\\`.* TO '{userName}'@'%'; "
|
|
||||||
+ $"FLUSH PRIVILEGES;";
|
|
||||||
|
|
||||||
var cmd = $"mysql -h {mySqlHost} -P {mySqlPort} -u {mySqlAdminUser} -p'{safePwd}' -e \"{sql}\" 2>&1";
|
var csb = new MySqlConnectionStringBuilder
|
||||||
|
|
||||||
_logger.LogInformation("Creating MySQL database {Db} and user {User}", dbName, userName);
|
|
||||||
|
|
||||||
var (exitCode, stdout, stderr) = await runSshCommand(cmd);
|
|
||||||
|
|
||||||
if (exitCode == 0)
|
|
||||||
{
|
{
|
||||||
_logger.LogInformation("MySQL database and user created: {Db} / {User}", dbName, userName);
|
Server = mySqlHost,
|
||||||
return (true, $"Database '{dbName}' and user '{userName}' created.");
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
var error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
|
await using (var cmd = connection.CreateCommand())
|
||||||
_logger.LogError("MySQL setup failed: {Error}", error);
|
{
|
||||||
return (false, $"MySQL setup failed: {error.Trim()}");
|
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.");
|
||||||
|
}
|
||||||
|
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)
|
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.XiboUsername != null) instance.XiboUsername = dto.XiboUsername;
|
||||||
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
|
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
|
||||||
if (dto.CifsServer != null) instance.CifsServer = dto.CifsServer;
|
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.CifsUsername != null) instance.CifsUsername = dto.CifsUsername;
|
||||||
if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword;
|
if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword;
|
||||||
if (dto.CifsExtraOptions != null) instance.CifsExtraOptions = dto.CifsExtraOptions;
|
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");
|
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||||
|
|
||||||
// Use per-instance CIFS credentials
|
// Use per-instance CIFS credentials, falling back to global settings
|
||||||
var cifsServer = instance.CifsServer;
|
var cifsServer = instance.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
|
||||||
var cifsShareBasePath = instance.CifsShareBasePath;
|
var cifsShareName = instance.CifsShareName ?? await _settings.GetAsync(SettingsService.CifsShareName);
|
||||||
var cifsUsername = instance.CifsUsername;
|
var cifsShareFolder = instance.CifsShareFolder ?? await _settings.GetAsync(SettingsService.CifsShareFolder);
|
||||||
var cifsPassword = instance.CifsPassword;
|
var cifsUsername = instance.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
|
||||||
var cifsOptions = instance.CifsExtraOptions ?? "file_mode=0777,dir_mode=0777";
|
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 cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||||
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||||
@@ -340,6 +417,17 @@ public class InstanceService
|
|||||||
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||||
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||||
|
|
||||||
|
// ── 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
|
var renderCtx = new RenderContext
|
||||||
{
|
{
|
||||||
CustomerName = instance.CustomerName,
|
CustomerName = instance.CustomerName,
|
||||||
@@ -367,18 +455,19 @@ public class InstanceService
|
|||||||
PhpPostMaxSize = phpPostMaxSize,
|
PhpPostMaxSize = phpPostMaxSize,
|
||||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||||
IncludeNewt = true,
|
|
||||||
PangolinEndpoint = pangolinEndpoint,
|
PangolinEndpoint = pangolinEndpoint,
|
||||||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
|
||||||
CifsServer = cifsServer,
|
CifsServer = cifsServer,
|
||||||
CifsShareBasePath = cifsShareBasePath,
|
CifsShareName = cifsShareName,
|
||||||
|
CifsShareFolder = cifsShareFolder,
|
||||||
CifsUsername = cifsUsername,
|
CifsUsername = cifsUsername,
|
||||||
CifsPassword = cifsPassword,
|
CifsPassword = cifsPassword,
|
||||||
CifsExtraOptions = cifsOptions,
|
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)
|
if (_dockerOptions.ValidateBeforeDeploy)
|
||||||
{
|
{
|
||||||
@@ -387,6 +476,30 @@ public class InstanceService
|
|||||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
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);
|
var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true);
|
||||||
if (!deployResult.Success)
|
if (!deployResult.Success)
|
||||||
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");
|
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ public class SettingsService
|
|||||||
|
|
||||||
// CIFS
|
// CIFS
|
||||||
public const string CifsServer = "Cifs.Server";
|
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 CifsUsername = "Cifs.Username";
|
||||||
public const string CifsPassword = "Cifs.Password";
|
public const string CifsPassword = "Cifs.Password";
|
||||||
public const string CifsOptions = "Cifs.Options";
|
public const string CifsOptions = "Cifs.Options";
|
||||||
|
|||||||
@@ -118,11 +118,11 @@ public class App : Application
|
|||||||
// SSH services (singletons — maintain connections)
|
// SSH services (singletons — maintain connections)
|
||||||
services.AddSingleton<SshConnectionService>();
|
services.AddSingleton<SshConnectionService>();
|
||||||
|
|
||||||
// Docker services via SSH (scoped so they get fresh per-operation context)
|
// Docker services via SSH (singletons — SetHost() must persist across scopes)
|
||||||
services.AddTransient<SshDockerCliService>();
|
services.AddSingleton<SshDockerCliService>();
|
||||||
services.AddTransient<SshDockerSecretsService>();
|
services.AddSingleton<SshDockerSecretsService>();
|
||||||
services.AddTransient<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
|
services.AddSingleton<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
|
||||||
services.AddTransient<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
|
services.AddSingleton<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
|
||||||
|
|
||||||
// Core services
|
// Core services
|
||||||
services.AddTransient<SettingsService>();
|
services.AddTransient<SettingsService>();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" 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="Microsoft.Extensions.Hosting" Version="9.0.2" />
|
||||||
|
<PackageReference Include="MySqlConnector" Version="2.5.0" />
|
||||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
|||||||
@@ -151,9 +151,145 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
.ToList();
|
.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()
|
private void EnsureHost()
|
||||||
{
|
{
|
||||||
if (_currentHost == null)
|
if (_currentHost == null)
|
||||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
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)
|
// CIFS / SMB credentials (per-instance, defaults loaded from global settings)
|
||||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
[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 _cifsUsername = string.Empty;
|
||||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||||
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
|
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
|
||||||
@@ -110,7 +111,8 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
using var scope = _services.CreateScope();
|
using var scope = _services.CreateScope();
|
||||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
|
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;
|
CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty;
|
||||||
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
|
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
|
||||||
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||||
@@ -148,7 +150,6 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
dockerCli.SetHost(SelectedSshHost);
|
dockerCli.SetHost(SelectedSshHost);
|
||||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||||
dockerSecrets.SetHost(SelectedSshHost);
|
dockerSecrets.SetHost(SelectedSshHost);
|
||||||
var ssh = _services.GetRequiredService<SshConnectionService>();
|
|
||||||
|
|
||||||
using var scope = _services.CreateScope();
|
using var scope = _services.CreateScope();
|
||||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||||
@@ -161,12 +162,11 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
SetProgress(20, "Generating secrets...");
|
SetProgress(20, "Generating secrets...");
|
||||||
var mysqlPassword = GenerateRandomPassword(32);
|
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...");
|
SetProgress(35, "Creating MySQL database and user...");
|
||||||
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
|
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
|
||||||
Abbrev,
|
Abbrev,
|
||||||
mysqlPassword,
|
mysqlPassword);
|
||||||
cmd => ssh.RunCommandAsync(SelectedSshHost, cmd));
|
|
||||||
|
|
||||||
AppendOutput($"[MySQL] {mysqlMsg}");
|
AppendOutput($"[MySQL] {mysqlMsg}");
|
||||||
if (!mysqlOk)
|
if (!mysqlOk)
|
||||||
@@ -195,7 +195,8 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
||||||
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
||||||
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
|
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(),
|
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
|
||||||
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
|
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
|
||||||
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
|
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MySqlConnector;
|
||||||
using OTSSignsOrchestrator.Core.Services;
|
using OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||||
@@ -42,7 +43,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
|
|
||||||
// ── CIFS ────────────────────────────────────────────────────────────────
|
// ── CIFS ────────────────────────────────────────────────────────────────
|
||||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
[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 _cifsUsername = string.Empty;
|
||||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||||
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
|
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
|
||||||
@@ -100,7 +102,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
|
|
||||||
// CIFS
|
// CIFS
|
||||||
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty);
|
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);
|
CifsUsername = await svc.GetAsync(SettingsService.CifsUsername, string.Empty);
|
||||||
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
|
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
|
||||||
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||||
@@ -166,7 +169,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
|
|
||||||
// CIFS
|
// CIFS
|
||||||
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false),
|
(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.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false),
|
||||||
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
|
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
|
||||||
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
|
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
|
||||||
@@ -208,30 +212,34 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
StatusMessage = "Testing MySQL connection via SSH...";
|
StatusMessage = "Testing MySQL connection...";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// The test runs a mysql --version or a simple SELECT 1 query via SSH
|
if (!int.TryParse(MySqlPort, out var port))
|
||||||
// We need an SshHost to route through — use the first available
|
port = 3306;
|
||||||
using var scope = _services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<OTSSignsOrchestrator.Core.Data.XiboContext>();
|
|
||||||
var host = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
|
|
||||||
.FirstOrDefaultAsync(db.SshHosts);
|
|
||||||
|
|
||||||
if (host == null)
|
var csb = new MySqlConnectionStringBuilder
|
||||||
{
|
{
|
||||||
StatusMessage = "No SSH hosts configured. Add one in the Hosts page first.";
|
Server = MySqlHost,
|
||||||
return;
|
Port = (uint)port,
|
||||||
|
UserID = MySqlAdminUser,
|
||||||
|
Password = MySqlAdminPassword,
|
||||||
|
ConnectionTimeout = 10,
|
||||||
|
SslMode = MySqlSslMode.Preferred,
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var connection = new MySqlConnection(csb.ConnectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
await using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT 1";
|
||||||
|
await cmd.ExecuteScalarAsync();
|
||||||
|
|
||||||
|
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port}).";
|
||||||
}
|
}
|
||||||
|
catch (MySqlException ex)
|
||||||
var ssh = _services.GetRequiredService<Services.SshConnectionService>();
|
{
|
||||||
var port = int.TryParse(MySqlPort, out var p) ? p : 3306;
|
StatusMessage = $"MySQL connection failed: {ex.Message}";
|
||||||
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);
|
|
||||||
|
|
||||||
StatusMessage = exitCode == 0
|
|
||||||
? $"MySQL connection successful via {host.Label}."
|
|
||||||
: $"MySQL connection failed: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}";
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,8 +54,11 @@
|
|||||||
<TextBlock Text="CIFS Server" FontSize="12" />
|
<TextBlock Text="CIFS Server" FontSize="12" />
|
||||||
<TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" />
|
<TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" />
|
||||||
|
|
||||||
<TextBlock Text="Share Base Path" FontSize="12" />
|
<TextBlock Text="Share Name" FontSize="12" />
|
||||||
<TextBox Text="{Binding CifsShareBasePath}" Watermark="e.g. /share/cms" />
|
<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" />
|
<TextBlock Text="Username" FontSize="12" />
|
||||||
<TextBox Text="{Binding CifsUsername}" Watermark="SMB username" />
|
<TextBox Text="{Binding CifsUsername}" Watermark="SMB username" />
|
||||||
|
|||||||
@@ -127,8 +127,11 @@
|
|||||||
<TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" />
|
<TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" />
|
||||||
<TextBox Text="{Binding CifsServer}" Watermark="nas.local" />
|
<TextBox Text="{Binding CifsServer}" Watermark="nas.local" />
|
||||||
|
|
||||||
<TextBlock Text="Share Base Path" FontSize="12" />
|
<TextBlock Text="Share Name" FontSize="12" />
|
||||||
<TextBox Text="{Binding CifsShareBasePath}" Watermark="/share/cms" />
|
<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" />
|
<TextBlock Text="Username" FontSize="12" />
|
||||||
<TextBox Text="{Binding CifsUsername}" Watermark="smbuser" />
|
<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