feat: Add main application views and structure
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
- Implemented CreateInstanceView for creating new instances. - Added HostsView for managing SSH hosts with CRUD operations. - Created InstancesView for displaying and managing instances. - Developed LogsView for viewing operation logs. - Introduced SecretsView for managing secrets associated with hosts. - Established SettingsView for configuring application settings. - Created MainWindow as the main application window with navigation. - Added app manifest and configuration files for logging and settings.
This commit is contained in:
21
OTSSignsOrchestrator.Core/Configuration/AppConstants.cs
Normal file
21
OTSSignsOrchestrator.Core/Configuration/AppConstants.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace OTSSignsOrchestrator.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Shared constants for the application.
|
||||
/// </summary>
|
||||
public static class AppConstants
|
||||
{
|
||||
public const string AdminRole = "Admin";
|
||||
public const string ViewerRole = "Viewer";
|
||||
|
||||
/// <summary>Docker secret name for the global SMTP password.</summary>
|
||||
public const string GlobalSmtpSecretName = "global_smtp_password";
|
||||
|
||||
/// <summary>Build a per-customer MySQL password secret name.</summary>
|
||||
public static string CustomerMysqlSecretName(string customerName)
|
||||
=> $"{SanitizeName(customerName)}_mysql_password";
|
||||
|
||||
/// <summary>Sanitize a customer name for use in Docker/secret names.</summary>
|
||||
public static string SanitizeName(string name)
|
||||
=> new string(name.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray()).ToLowerInvariant();
|
||||
}
|
||||
76
OTSSignsOrchestrator.Core/Configuration/AppOptions.cs
Normal file
76
OTSSignsOrchestrator.Core/Configuration/AppOptions.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
namespace OTSSignsOrchestrator.Core.Configuration;
|
||||
|
||||
public class FileLoggingOptions
|
||||
{
|
||||
public const string SectionName = "FileLogging";
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string Path { get; set; } = "logs";
|
||||
public string RollingInterval { get; set; } = "Day";
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
public long FileSizeLimitBytes { get; set; } = 100 * 1024 * 1024; // 100MB
|
||||
}
|
||||
|
||||
public class GitOptions
|
||||
{
|
||||
public const string SectionName = "Git";
|
||||
public string CacheDir { get; set; } = ".template-cache";
|
||||
public int CacheTtlMinutes { get; set; } = 60;
|
||||
public int ShallowCloneDepth { get; set; } = 1;
|
||||
}
|
||||
|
||||
public class DockerOptions
|
||||
{
|
||||
public const string SectionName = "Docker";
|
||||
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";
|
||||
}
|
||||
|
||||
public class InstanceDefaultsOptions
|
||||
{
|
||||
public const string SectionName = "InstanceDefaults";
|
||||
|
||||
/// <summary>Default template repo URL if not overridden per-instance.</summary>
|
||||
public string? TemplateRepoUrl { get; set; }
|
||||
public string? TemplateRepoPat { get; set; }
|
||||
|
||||
/// <summary>Template for CMS server hostname. Use {abbrev} as placeholder.</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;
|
||||
|
||||
public int BaseHostHttpPort { get; set; } = 8080;
|
||||
|
||||
/// <summary>Template for theme 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>Subfolder name on CIFS share for the library volume. Use {abbrev} as placeholder.</summary>
|
||||
public string LibraryShareSubPath { get; set; } = "{abbrev}-cms-library";
|
||||
|
||||
public string MySqlDatabaseTemplate { get; set; } = "{abbrev}_cms_db";
|
||||
public string MySqlUserTemplate { get; set; } = "{abbrev}_cms";
|
||||
}
|
||||
27
OTSSignsOrchestrator.Core/Data/DesignTimeDbContextFactory.cs
Normal file
27
OTSSignsOrchestrator.Core/Data/DesignTimeDbContextFactory.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for EF Core migrations tooling.
|
||||
/// </summary>
|
||||
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<XiboContext>
|
||||
{
|
||||
public XiboContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<XiboContext>();
|
||||
optionsBuilder.UseSqlite("Data Source=design-time.db");
|
||||
|
||||
// Set up a temporary DataProtection provider for design-time use
|
||||
var services = new ServiceCollection();
|
||||
services.AddDataProtection()
|
||||
.SetApplicationName("OTSSignsOrchestrator");
|
||||
var sp = services.BuildServiceProvider();
|
||||
var dpProvider = sp.GetRequiredService<IDataProtectionProvider>();
|
||||
|
||||
return new XiboContext(optionsBuilder.Options, dpProvider);
|
||||
}
|
||||
}
|
||||
95
OTSSignsOrchestrator.Core/Data/XiboContext.cs
Normal file
95
OTSSignsOrchestrator.Core/Data/XiboContext.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
public class XiboContext : DbContext
|
||||
{
|
||||
private readonly IDataProtectionProvider? _dataProtection;
|
||||
|
||||
public XiboContext(DbContextOptions<XiboContext> options, IDataProtectionProvider? dataProtection = null)
|
||||
: base(options)
|
||||
{
|
||||
_dataProtection = dataProtection;
|
||||
}
|
||||
|
||||
public DbSet<CmsInstance> CmsInstances => Set<CmsInstance>();
|
||||
public DbSet<SshHost> SshHosts => Set<SshHost>();
|
||||
public DbSet<SecretMetadata> SecretMetadata => Set<SecretMetadata>();
|
||||
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// --- CmsInstance ---
|
||||
modelBuilder.Entity<CmsInstance>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.StackName).IsUnique();
|
||||
entity.HasIndex(e => e.CustomerName);
|
||||
entity.HasQueryFilter(e => e.DeletedAt == null);
|
||||
|
||||
entity.HasOne(e => e.SshHost)
|
||||
.WithMany(h => h.Instances)
|
||||
.HasForeignKey(e => e.SshHostId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
if (_dataProtection != null)
|
||||
{
|
||||
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.CmsInstance");
|
||||
var pwdConverter = new ValueConverter<string?, string?>(
|
||||
v => v != null ? protector.Protect(v) : null,
|
||||
v => v != null ? protector.Unprotect(v) : null);
|
||||
|
||||
entity.Property(e => e.XiboPassword).HasConversion(pwdConverter);
|
||||
entity.Property(e => e.XiboUsername).HasConversion(pwdConverter);
|
||||
entity.Property(e => e.TemplateRepoPat).HasConversion(pwdConverter);
|
||||
entity.Property(e => e.CifsPassword).HasConversion(pwdConverter);
|
||||
}
|
||||
});
|
||||
|
||||
// --- SshHost ---
|
||||
modelBuilder.Entity<SshHost>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.Label).IsUnique();
|
||||
|
||||
if (_dataProtection != null)
|
||||
{
|
||||
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.SshHost");
|
||||
var passphraseConverter = new ValueConverter<string?, string?>(
|
||||
v => v != null ? protector.Protect(v) : null,
|
||||
v => v != null ? protector.Unprotect(v) : null);
|
||||
var passwordConverter = new ValueConverter<string?, string?>(
|
||||
v => v != null ? protector.Protect(v) : null,
|
||||
v => v != null ? protector.Unprotect(v) : null);
|
||||
|
||||
entity.Property(e => e.KeyPassphrase).HasConversion(passphraseConverter);
|
||||
entity.Property(e => e.Password).HasConversion(passwordConverter);
|
||||
}
|
||||
});
|
||||
|
||||
// --- SecretMetadata ---
|
||||
modelBuilder.Entity<SecretMetadata>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.Name).IsUnique();
|
||||
});
|
||||
|
||||
// --- OperationLog ---
|
||||
modelBuilder.Entity<OperationLog>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.Timestamp);
|
||||
entity.HasIndex(e => e.InstanceId);
|
||||
entity.HasIndex(e => e.Operation);
|
||||
});
|
||||
|
||||
// --- AppSetting ---
|
||||
modelBuilder.Entity<AppSetting>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Key);
|
||||
entity.HasIndex(e => e.Category);
|
||||
});
|
||||
}
|
||||
}
|
||||
290
OTSSignsOrchestrator.Core/Migrations/20260217004115_DesktopInitial.Designer.cs
generated
Normal file
290
OTSSignsOrchestrator.Core/Migrations/20260217004115_DesktopInitial.Designer.cs
generated
Normal file
@@ -0,0 +1,290 @@
|
||||
// <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("20260217004115_DesktopInitial")]
|
||||
partial class DesktopInitial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CmsServerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Constraints")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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,175 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DesktopInitial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SecretMetadata",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
IsGlobal = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CustomerName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastRotatedAt = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SecretMetadata", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SshHosts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Label = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Host = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
Port = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Username = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
PrivateKeyPath = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
|
||||
KeyPassphrase = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
|
||||
Password = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
|
||||
UseKeyAuth = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastTestedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
LastTestSuccess = table.Column<bool>(type: "INTEGER", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SshHosts", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CmsInstances",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
CustomerName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
StackName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
CmsServerName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
HostHttpPort = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ThemeHostPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
LibraryHostPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
SmtpServer = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
SmtpUsername = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Constraints = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
|
||||
TemplateRepoUrl = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
TemplateRepoPat = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
|
||||
TemplateLastFetch = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
TemplateCacheKey = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
XiboUsername = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
|
||||
XiboPassword = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
|
||||
XiboApiTestStatus = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
XiboApiTestedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
SshHostId = table.Column<Guid>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CmsInstances", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CmsInstances_SshHosts_SshHostId",
|
||||
column: x => x.SshHostId,
|
||||
principalTable: "SshHosts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OperationLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Operation = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
InstanceId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
UserId = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Message = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
|
||||
DurationMs = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
Timestamp = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OperationLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OperationLogs_CmsInstances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "CmsInstances",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CmsInstances_CustomerName",
|
||||
table: "CmsInstances",
|
||||
column: "CustomerName");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CmsInstances_SshHostId",
|
||||
table: "CmsInstances",
|
||||
column: "SshHostId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CmsInstances_StackName",
|
||||
table: "CmsInstances",
|
||||
column: "StackName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OperationLogs_InstanceId",
|
||||
table: "OperationLogs",
|
||||
column: "InstanceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OperationLogs_Operation",
|
||||
table: "OperationLogs",
|
||||
column: "Operation");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OperationLogs_Timestamp",
|
||||
table: "OperationLogs",
|
||||
column: "Timestamp");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SecretMetadata_Name",
|
||||
table: "SecretMetadata",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SshHosts_Label",
|
||||
table: "SshHosts",
|
||||
column: "Label",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "OperationLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SecretMetadata");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CmsInstances");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SshHosts");
|
||||
}
|
||||
}
|
||||
}
|
||||
295
OTSSignsOrchestrator.Core/Migrations/20260218140239_AddCustomerAbbrev.Designer.cs
generated
Normal file
295
OTSSignsOrchestrator.Core/Migrations/20260218140239_AddCustomerAbbrev.Designer.cs
generated
Normal file
@@ -0,0 +1,295 @@
|
||||
// <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("20260218140239_AddCustomerAbbrev")]
|
||||
partial class AddCustomerAbbrev
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CmsServerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Constraints")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerAbbrev")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("HostHttpPort")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LibraryHostPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomerAbbrev : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CustomerAbbrev",
|
||||
table: "CmsInstances",
|
||||
type: "TEXT",
|
||||
maxLength: 3,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomerAbbrev",
|
||||
table: "CmsInstances");
|
||||
}
|
||||
}
|
||||
}
|
||||
323
OTSSignsOrchestrator.Core/Migrations/20260218143812_AddAppSettings.Designer.cs
generated
Normal file
323
OTSSignsOrchestrator.Core/Migrations/20260218143812_AddAppSettings.Designer.cs
generated
Normal file
@@ -0,0 +1,323 @@
|
||||
// <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("20260218143812_AddAppSettings")]
|
||||
partial class AddAppSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSensitive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.HasIndex("Category");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CmsServerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Constraints")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerAbbrev")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("HostHttpPort")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LibraryHostPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAppSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Value = table.Column<string>(type: "TEXT", maxLength: 4000, nullable: true),
|
||||
Category = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
IsSensitive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppSettings", x => x.Key);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppSettings_Category",
|
||||
table: "AppSettings",
|
||||
column: "Category");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
343
OTSSignsOrchestrator.Core/Migrations/20260218144537_AddPerInstanceCifsCredentials.Designer.cs
generated
Normal file
343
OTSSignsOrchestrator.Core/Migrations/20260218144537_AddPerInstanceCifsCredentials.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("20260218144537_AddPerInstanceCifsCredentials")]
|
||||
partial class AddPerInstanceCifsCredentials
|
||||
{
|
||||
/// <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>("CifsShareBasePath")
|
||||
.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,73 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPerInstanceCifsCredentials : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CifsExtraOptions",
|
||||
table: "CmsInstances",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CifsPassword",
|
||||
table: "CmsInstances",
|
||||
type: "TEXT",
|
||||
maxLength: 1000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CifsServer",
|
||||
table: "CmsInstances",
|
||||
type: "TEXT",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CifsShareBasePath",
|
||||
table: "CmsInstances",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CifsUsername",
|
||||
table: "CmsInstances",
|
||||
type: "TEXT",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CifsExtraOptions",
|
||||
table: "CmsInstances");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CifsPassword",
|
||||
table: "CmsInstances");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CifsServer",
|
||||
table: "CmsInstances");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CifsShareBasePath",
|
||||
table: "CmsInstances");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CifsUsername",
|
||||
table: "CmsInstances");
|
||||
}
|
||||
}
|
||||
}
|
||||
340
OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs
Normal file
340
OTSSignsOrchestrator.Core/Migrations/XiboContextModelSnapshot.cs
Normal file
@@ -0,0 +1,340 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
[DbContext(typeof(XiboContext))]
|
||||
partial class XiboContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(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>("CifsShareBasePath")
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
41
OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs
Normal file
41
OTSSignsOrchestrator.Core/Models/DTOs/CreateInstanceDto.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
public class CreateInstanceDto
|
||||
{
|
||||
[Required, MaxLength(100)]
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Exactly 3 lowercase letters used to derive all resource names.</summary>
|
||||
[Required, MaxLength(3), MinLength(3), RegularExpression("^[a-z]{3}$", ErrorMessage = "Abbreviation must be exactly 3 lowercase letters.")]
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>SSH host to deploy to.</summary>
|
||||
public Guid? SshHostId { get; set; }
|
||||
|
||||
/// <summary>Pangolin Newt ID (optional — tunnel service excluded if not provided).</summary>
|
||||
[MaxLength(500)]
|
||||
public string? NewtId { get; set; }
|
||||
|
||||
/// <summary>Pangolin Newt Secret (optional — tunnel service excluded if not provided).</summary>
|
||||
[MaxLength(500)]
|
||||
public string? NewtSecret { get; set; }
|
||||
|
||||
// ── CIFS / SMB credentials (optional — falls back to global settings) ──
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? CifsServer { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsShareBasePath { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? CifsUsername { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsPassword { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsExtraOptions { get; set; }
|
||||
}
|
||||
13
OTSSignsOrchestrator.Core/Models/DTOs/DeploymentResultDto.cs
Normal file
13
OTSSignsOrchestrator.Core/Models/DTOs/DeploymentResultDto.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
public class DeploymentResultDto
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string StackName { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string? Output { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public int ExitCode { get; set; }
|
||||
public long DurationMs { get; set; }
|
||||
public int ServiceCount { get; set; }
|
||||
}
|
||||
8
OTSSignsOrchestrator.Core/Models/DTOs/TemplateConfig.cs
Normal file
8
OTSSignsOrchestrator.Core/Models/DTOs/TemplateConfig.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
public class TemplateConfig
|
||||
{
|
||||
public string Yaml { get; set; } = string.Empty;
|
||||
public List<string> EnvLines { get; set; } = new();
|
||||
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
43
OTSSignsOrchestrator.Core/Models/DTOs/UpdateInstanceDto.cs
Normal file
43
OTSSignsOrchestrator.Core/Models/DTOs/UpdateInstanceDto.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
public class UpdateInstanceDto
|
||||
{
|
||||
[MaxLength(500)]
|
||||
public string? TemplateRepoUrl { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? TemplateRepoPat { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? SmtpServer { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? SmtpUsername { get; set; }
|
||||
|
||||
public List<string>? Constraints { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? XiboUsername { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? XiboPassword { get; set; }
|
||||
|
||||
// ── CIFS / SMB credentials (per-instance) ──
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? CifsServer { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsShareBasePath { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? CifsUsername { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsPassword { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsExtraOptions { get; set; }
|
||||
}
|
||||
23
OTSSignsOrchestrator.Core/Models/Entities/AppSetting.cs
Normal file
23
OTSSignsOrchestrator.Core/Models/Entities/AppSetting.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Key-value application setting persisted in the local database.
|
||||
/// Sensitive values are encrypted at rest via DataProtection.
|
||||
/// </summary>
|
||||
public class AppSetting
|
||||
{
|
||||
[Key, MaxLength(200)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(4000)]
|
||||
public string? Value { get; set; }
|
||||
|
||||
[Required, MaxLength(50)]
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
public bool IsSensitive { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
117
OTSSignsOrchestrator.Core/Models/Entities/CmsInstance.cs
Normal file
117
OTSSignsOrchestrator.Core/Models/Entities/CmsInstance.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
public enum InstanceStatus
|
||||
{
|
||||
Deploying,
|
||||
Active,
|
||||
Error,
|
||||
Deleted
|
||||
}
|
||||
|
||||
public enum XiboApiTestStatus
|
||||
{
|
||||
Unknown,
|
||||
Success,
|
||||
Failed
|
||||
}
|
||||
|
||||
public class CmsInstance
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Exactly 3 lowercase letters used to derive all resource names.</summary>
|
||||
[MaxLength(3)]
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string StackName { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string CmsServerName { get; set; } = string.Empty;
|
||||
|
||||
[Required, Range(1024, 65535)]
|
||||
public int HostHttpPort { get; set; }
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string ThemeHostPath { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string LibraryHostPath { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string SmtpUsername { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// JSON array of placement constraints, e.g. ["node.labels.xibo==true"]
|
||||
/// </summary>
|
||||
[MaxLength(2000)]
|
||||
public string? Constraints { get; set; }
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string TemplateRepoUrl { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? TemplateRepoPat { get; set; }
|
||||
|
||||
public DateTime? TemplateLastFetch { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? TemplateCacheKey { get; set; }
|
||||
|
||||
public InstanceStatus Status { get; set; } = InstanceStatus.Deploying;
|
||||
|
||||
/// <summary>Encrypted Xibo admin username for API access.</summary>
|
||||
[MaxLength(500)]
|
||||
public string? XiboUsername { get; set; }
|
||||
|
||||
/// <summary>Encrypted Xibo admin password. Never logged; encrypted at rest.</summary>
|
||||
[MaxLength(1000)]
|
||||
public string? XiboPassword { get; set; }
|
||||
|
||||
public XiboApiTestStatus XiboApiTestStatus { get; set; } = XiboApiTestStatus.Unknown;
|
||||
|
||||
public DateTime? XiboApiTestedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Soft delete marker.</summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
|
||||
// ── CIFS / SMB credentials (per-instance) ─────────────────────────────
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? CifsServer { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsShareBasePath { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? CifsUsername { get; set; }
|
||||
|
||||
/// <summary>Encrypted CIFS password. Never logged; encrypted at rest.</summary>
|
||||
[MaxLength(1000)]
|
||||
public string? CifsPassword { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? CifsExtraOptions { get; set; }
|
||||
|
||||
/// <summary>ID of the SshHost this instance is deployed to.</summary>
|
||||
public Guid? SshHostId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(SshHostId))]
|
||||
public SshHost? SshHost { get; set; }
|
||||
|
||||
public ICollection<OperationLog> OperationLogs { get; set; } = new List<OperationLog>();
|
||||
}
|
||||
48
OTSSignsOrchestrator.Core/Models/Entities/OperationLog.cs
Normal file
48
OTSSignsOrchestrator.Core/Models/Entities/OperationLog.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
public enum OperationType
|
||||
{
|
||||
Create,
|
||||
Update,
|
||||
Delete,
|
||||
TestXibo,
|
||||
TestIdP,
|
||||
DeploymentStatus,
|
||||
Other
|
||||
}
|
||||
|
||||
public enum OperationStatus
|
||||
{
|
||||
Pending,
|
||||
Success,
|
||||
Failure
|
||||
}
|
||||
|
||||
public class OperationLog
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public OperationType Operation { get; set; }
|
||||
|
||||
public Guid? InstanceId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(InstanceId))]
|
||||
public CmsInstance? Instance { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
public OperationStatus Status { get; set; } = OperationStatus.Pending;
|
||||
|
||||
/// <summary>Human-readable message. NEVER includes secret values.</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? Message { get; set; }
|
||||
|
||||
public long? DurationMs { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
21
OTSSignsOrchestrator.Core/Models/Entities/SecretMetadata.cs
Normal file
21
OTSSignsOrchestrator.Core/Models/Entities/SecretMetadata.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
public class SecretMetadata
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public bool IsGlobal { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? LastRotatedAt { get; set; }
|
||||
}
|
||||
59
OTSSignsOrchestrator.Core/Models/Entities/SshHost.cs
Normal file
59
OTSSignsOrchestrator.Core/Models/Entities/SshHost.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a remote Docker Swarm host accessible over SSH.
|
||||
/// SSH key paths or encrypted passwords are stored for authentication.
|
||||
/// </summary>
|
||||
public class SshHost
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
[Range(1, 65535)]
|
||||
public int Port { get; set; } = 22;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the SSH private key file on the local machine.
|
||||
/// </summary>
|
||||
[MaxLength(1000)]
|
||||
public string? PrivateKeyPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Encrypted passphrase for the SSH key (if any). Protected by DataProtection.
|
||||
/// </summary>
|
||||
[MaxLength(2000)]
|
||||
public string? KeyPassphrase { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Encrypted SSH password (if key-based auth is not used). Protected by DataProtection.
|
||||
/// </summary>
|
||||
[MaxLength(2000)]
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to prefer key-based auth over password.
|
||||
/// </summary>
|
||||
public bool UseKeyAuth { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Last time a connection was successfully tested.</summary>
|
||||
public DateTime? LastTestedAt { get; set; }
|
||||
|
||||
public bool? LastTestSuccess { get; set; }
|
||||
|
||||
public ICollection<CmsInstance> Instances { get; set; } = new List<CmsInstance>();
|
||||
}
|
||||
27
OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj
Normal file
27
OTSSignsOrchestrator.Core/OTSSignsOrchestrator.Core.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.2" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
361
OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs
Normal file
361
OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs
Normal file
@@ -0,0 +1,361 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Docker Compose v3.9 YAML for a Xibo CMS stack.
|
||||
/// Combined format: no separate config.env, no MySQL container (external DB),
|
||||
/// CIFS volumes, Newt tunnel service, and inline environment variables.
|
||||
/// </summary>
|
||||
public class ComposeRenderService
|
||||
{
|
||||
private readonly ILogger<ComposeRenderService> _logger;
|
||||
|
||||
public ComposeRenderService(ILogger<ComposeRenderService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Render(RenderContext ctx)
|
||||
{
|
||||
_logger.LogInformation("Rendering Compose for stack: {StackName}", ctx.StackName);
|
||||
|
||||
var root = new YamlMappingNode();
|
||||
|
||||
// Version
|
||||
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
|
||||
|
||||
// Comment — customer name (added as a YAML comment isn't natively supported,
|
||||
// so we prepend it manually after serialization)
|
||||
BuildServices(root, ctx);
|
||||
BuildNetworks(root, ctx);
|
||||
BuildVolumes(root, ctx);
|
||||
BuildSecrets(root, ctx);
|
||||
|
||||
var doc = new YamlDocument(root);
|
||||
var stream = new YamlStream(doc);
|
||||
|
||||
using var writer = new StringWriter();
|
||||
stream.Save(writer, assignAnchors: false);
|
||||
var output = writer.ToString()
|
||||
.Replace("...\n", "").Replace("...", "");
|
||||
|
||||
// Prepend customer name comment
|
||||
output = $"# Customer: {ctx.CustomerName}\n{output}";
|
||||
|
||||
_logger.LogDebug("Compose rendered for {StackName}: {ServiceCount} services",
|
||||
ctx.StackName, 4);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// ── Services ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildServices(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var services = new YamlMappingNode();
|
||||
root.Children[new YamlScalarNode("services")] = services;
|
||||
|
||||
BuildWebService(services, ctx);
|
||||
BuildMemcachedService(services, ctx);
|
||||
BuildQuickChartService(services, ctx);
|
||||
|
||||
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))
|
||||
opts += $",{ctx.CifsExtraOptions}";
|
||||
|
||||
var volDef = new YamlMappingNode
|
||||
{
|
||||
{ "driver", "local" },
|
||||
{ "driver_opts", new YamlMappingNode
|
||||
{
|
||||
{ "type", "cifs" },
|
||||
{ "device", device },
|
||||
{ "o", opts }
|
||||
}
|
||||
}
|
||||
};
|
||||
volumes.Children[new YamlScalarNode(volName)] = volDef;
|
||||
}
|
||||
else
|
||||
{
|
||||
volumes.Children[new YamlScalarNode(volName)] = new YamlMappingNode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Secrets ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildSecrets(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var secrets = new YamlMappingNode();
|
||||
root.Children[new YamlScalarNode("secrets")] = secrets;
|
||||
|
||||
foreach (var secretName in ctx.SecretNames)
|
||||
{
|
||||
secrets.Children[new YamlScalarNode(secretName)] =
|
||||
new YamlMappingNode { { "external", "true" } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Context object with all inputs needed to render a Compose file.</summary>
|
||||
public class RenderContext
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
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; } = 80;
|
||||
|
||||
// Docker images
|
||||
public string CmsImage { get; set; } = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
|
||||
public string MemcachedImage { get; set; } = "memcached:alpine";
|
||||
public string QuickChartImage { get; set; } = "ianw/quickchart";
|
||||
public string NewtImage { get; set; } = "fosrl/newt";
|
||||
|
||||
// Theme bind mount path on host
|
||||
public string ThemeHostPath { get; set; } = string.Empty;
|
||||
|
||||
// MySQL (external server)
|
||||
public string MySqlHost { get; set; } = string.Empty;
|
||||
public string MySqlPort { get; set; } = "3306";
|
||||
public string MySqlDatabase { get; set; } = "cms";
|
||||
public string MySqlUser { get; set; } = "cms";
|
||||
|
||||
// SMTP
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
public string SmtpUsername { get; set; } = string.Empty;
|
||||
public string SmtpPassword { get; set; } = string.Empty;
|
||||
public string SmtpUseTls { get; set; } = "YES";
|
||||
public string SmtpUseStartTls { get; set; } = "YES";
|
||||
public string SmtpRewriteDomain { get; set; } = string.Empty;
|
||||
public string SmtpHostname { get; set; } = string.Empty;
|
||||
public string SmtpFromLineOverride { get; set; } = "NO";
|
||||
|
||||
// PHP settings
|
||||
public string PhpPostMaxSize { get; set; } = "10G";
|
||||
public string PhpUploadMaxFilesize { get; set; } = "10G";
|
||||
public string PhpMaxExecutionTime { get; set; } = "600";
|
||||
|
||||
// Pangolin / Newt
|
||||
public bool IncludeNewt { get; set; } = true;
|
||||
public string PangolinEndpoint { get; set; } = "https://app.pangolin.net";
|
||||
public string? NewtId { get; set; }
|
||||
public string? NewtSecret { get; set; }
|
||||
|
||||
// CIFS volume settings
|
||||
public bool UseCifsVolumes { get; set; }
|
||||
public string? CifsServer { get; set; }
|
||||
public string? CifsShareBasePath { get; set; }
|
||||
public string? CifsUsername { get; set; }
|
||||
public string? CifsPassword { get; set; }
|
||||
public string? CifsExtraOptions { get; set; }
|
||||
|
||||
// Secrets to declare as external
|
||||
public List<string> SecretNames { get; set; } = new();
|
||||
|
||||
// Legacy — kept for backward compat but no longer used
|
||||
public string TemplateYaml { get; set; } = string.Empty;
|
||||
public Dictionary<string, string> TemplateEnvValues { get; set; } = new();
|
||||
public List<string> TemplateEnvLines { get; set; } = new();
|
||||
public List<string> Constraints { get; set; } = new();
|
||||
public string LibraryHostPath { get; set; } = string.Empty;
|
||||
}
|
||||
116
OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs
Normal file
116
OTSSignsOrchestrator.Core/Services/ComposeValidationService.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a rendered Compose YAML before deployment.
|
||||
/// </summary>
|
||||
public class ComposeValidationService
|
||||
{
|
||||
private readonly ILogger<ComposeValidationService> _logger;
|
||||
|
||||
public ComposeValidationService(ILogger<ComposeValidationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ValidationResult Validate(string composeYaml, string? customerAbbrev = null)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
YamlStream yamlStream;
|
||||
try
|
||||
{
|
||||
yamlStream = new YamlStream();
|
||||
using var reader = new StringReader(composeYaml);
|
||||
yamlStream.Load(reader);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"YAML parse error: {ex.Message}");
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
if (yamlStream.Documents.Count == 0)
|
||||
{
|
||||
errors.Add("YAML document is empty.");
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
var root = yamlStream.Documents[0].RootNode as YamlMappingNode;
|
||||
if (root == null)
|
||||
{
|
||||
errors.Add("YAML root is not a mapping node.");
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
if (!HasKey(root, "services"))
|
||||
errors.Add("Missing required top-level key: 'services'.");
|
||||
if (!HasKey(root, "secrets"))
|
||||
warnings.Add("Missing top-level key: 'secrets'. Secrets may not be available.");
|
||||
|
||||
if (HasKey(root, "services") && root.Children[new YamlScalarNode("services")] is YamlMappingNode services)
|
||||
{
|
||||
var presentServices = services.Children.Keys
|
||||
.OfType<YamlScalarNode>()
|
||||
.Select(k => k.Value!)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Determine required service suffixes
|
||||
var requiredSuffixes = new[] { "-web", "-memcached", "-quickchart" };
|
||||
var prefix = customerAbbrev ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(prefix))
|
||||
{
|
||||
foreach (var suffix in requiredSuffixes)
|
||||
{
|
||||
if (!presentServices.Contains($"{prefix}{suffix}"))
|
||||
errors.Add($"Missing required service: '{prefix}{suffix}'.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: at least check that there are web/memcached/quickchart services
|
||||
if (!presentServices.Any(s => s.EndsWith("-web", StringComparison.OrdinalIgnoreCase)))
|
||||
errors.Add("Missing a '-web' service.");
|
||||
if (!presentServices.Any(s => s.EndsWith("-memcached", StringComparison.OrdinalIgnoreCase)))
|
||||
errors.Add("Missing a '-memcached' service.");
|
||||
}
|
||||
|
||||
foreach (var (key, value) in services.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyNode && value is YamlMappingNode svcNode)
|
||||
{
|
||||
if (!HasKey(svcNode, "image"))
|
||||
errors.Add($"Service '{keyNode.Value}' is missing 'image'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secretsNode)
|
||||
{
|
||||
foreach (var (key, value) in secretsNode.Children)
|
||||
{
|
||||
if (value is YamlMappingNode secretNode)
|
||||
{
|
||||
if (!HasKey(secretNode, "external"))
|
||||
warnings.Add($"Secret '{(key as YamlScalarNode)?.Value}' is not external.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
private static bool HasKey(YamlMappingNode node, string key)
|
||||
=> node.Children.ContainsKey(new YamlScalarNode(key));
|
||||
}
|
||||
|
||||
public class ValidationResult
|
||||
{
|
||||
public List<string> Errors { get; set; } = new();
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
}
|
||||
161
OTSSignsOrchestrator.Core/Services/GitTemplateService.cs
Normal file
161
OTSSignsOrchestrator.Core/Services/GitTemplateService.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using LibGit2Sharp;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches template.yml and template.env from a Git repository using LibGit2Sharp.
|
||||
/// Caches clones on disk, keyed by SHA-256 hash of the repo URL.
|
||||
/// </summary>
|
||||
public class GitTemplateService
|
||||
{
|
||||
private readonly GitOptions _options;
|
||||
private readonly ILogger<GitTemplateService> _logger;
|
||||
|
||||
public GitTemplateService(IOptions<GitOptions> options, ILogger<GitTemplateService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TemplateConfig> FetchAsync(string repoUrl, string? pat = null, bool forceRefresh = false)
|
||||
{
|
||||
var cacheKey = ComputeCacheKey(repoUrl);
|
||||
var cacheDir = Path.Combine(_options.CacheDir, cacheKey);
|
||||
|
||||
_logger.LogInformation("Fetching templates from repo (cacheKey={CacheKey}, force={Force})", cacheKey, forceRefresh);
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
if (!Directory.Exists(cacheDir) || !Repository.IsValid(cacheDir))
|
||||
{
|
||||
CloneRepo(repoUrl, pat, cacheDir);
|
||||
}
|
||||
else if (forceRefresh || IsCacheStale(cacheDir))
|
||||
{
|
||||
FetchLatest(repoUrl, pat, cacheDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Using cached clone at {CacheDir}", cacheDir);
|
||||
}
|
||||
});
|
||||
|
||||
var yamlPath = FindFile(cacheDir, "template.yml");
|
||||
var envPath = FindFile(cacheDir, "template.env");
|
||||
|
||||
if (yamlPath == null)
|
||||
throw new FileNotFoundException("template.yml not found in repository root.");
|
||||
if (envPath == null)
|
||||
throw new FileNotFoundException("template.env not found in repository root.");
|
||||
|
||||
var yaml = await File.ReadAllTextAsync(yamlPath);
|
||||
var envLines = (await File.ReadAllLinesAsync(envPath))
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l) && !l.TrimStart().StartsWith('#'))
|
||||
.ToList();
|
||||
|
||||
return new TemplateConfig
|
||||
{
|
||||
Yaml = yaml,
|
||||
EnvLines = envLines,
|
||||
FetchedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private void CloneRepo(string repoUrl, string? pat, string cacheDir)
|
||||
{
|
||||
_logger.LogInformation("Shallow cloning repo to {CacheDir}", cacheDir);
|
||||
|
||||
if (Directory.Exists(cacheDir))
|
||||
Directory.Delete(cacheDir, recursive: true);
|
||||
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var cloneOpts = new CloneOptions { IsBare = false, RecurseSubmodules = false };
|
||||
|
||||
if (!string.IsNullOrEmpty(pat))
|
||||
{
|
||||
cloneOpts.FetchOptions.CredentialsProvider = (_, _, _) =>
|
||||
new UsernamePasswordCredentials { Username = pat, Password = string.Empty };
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Repository.Clone(repoUrl, cacheDir, cloneOpts);
|
||||
}
|
||||
catch (LibGit2SharpException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Git clone failed for repo (cacheKey={CacheKey})", ComputeCacheKey(repoUrl));
|
||||
throw new InvalidOperationException($"Failed to clone repository. Check URL and credentials. Error: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void FetchLatest(string repoUrl, string? pat, string cacheDir)
|
||||
{
|
||||
_logger.LogInformation("Fetching latest from origin in {CacheDir}", cacheDir);
|
||||
|
||||
try
|
||||
{
|
||||
using var repo = new Repository(cacheDir);
|
||||
|
||||
var fetchOpts = new FetchOptions();
|
||||
if (!string.IsNullOrEmpty(pat))
|
||||
{
|
||||
fetchOpts.CredentialsProvider = (_, _, _) =>
|
||||
new UsernamePasswordCredentials { Username = pat, Password = string.Empty };
|
||||
}
|
||||
|
||||
var remote = repo.Network.Remotes["origin"];
|
||||
var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification).ToArray();
|
||||
Commands.Fetch(repo, "origin", refSpecs, fetchOpts, "Auto-fetch for template update");
|
||||
|
||||
var trackingBranch = repo.Head.TrackedBranch;
|
||||
if (trackingBranch != null)
|
||||
{
|
||||
repo.Reset(ResetMode.Hard, trackingBranch.Tip);
|
||||
}
|
||||
|
||||
WriteCacheTimestamp(cacheDir);
|
||||
}
|
||||
catch (LibGit2SharpException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Git fetch failed for repo at {CacheDir}", cacheDir);
|
||||
throw new InvalidOperationException($"Failed to fetch latest templates. Error: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsCacheStale(string cacheDir)
|
||||
{
|
||||
var stampFile = Path.Combine(cacheDir, ".fetch-timestamp");
|
||||
if (!File.Exists(stampFile)) return true;
|
||||
|
||||
if (DateTime.TryParse(File.ReadAllText(stampFile), out var lastFetch))
|
||||
{
|
||||
return (DateTime.UtcNow - lastFetch).TotalMinutes > _options.CacheTtlMinutes;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WriteCacheTimestamp(string cacheDir)
|
||||
{
|
||||
var stampFile = Path.Combine(cacheDir, ".fetch-timestamp");
|
||||
File.WriteAllText(stampFile, DateTime.UtcNow.ToString("O"));
|
||||
}
|
||||
|
||||
private static string? FindFile(string dir, string fileName)
|
||||
{
|
||||
var path = Path.Combine(dir, fileName);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
private static string ComputeCacheKey(string repoUrl)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(repoUrl));
|
||||
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
28
OTSSignsOrchestrator.Core/Services/IDockerCliService.cs
Normal file
28
OTSSignsOrchestrator.Core/Services/IDockerCliService.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for Docker CLI stack operations (deploy, remove, list, inspect).
|
||||
/// Implementations may use local docker CLI or SSH-based remote execution.
|
||||
/// </summary>
|
||||
public interface IDockerCliService
|
||||
{
|
||||
Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false);
|
||||
Task<DeploymentResultDto> RemoveStackAsync(string stackName);
|
||||
Task<List<StackInfo>> ListStacksAsync();
|
||||
Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName);
|
||||
}
|
||||
|
||||
public class StackInfo
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int ServiceCount { get; set; }
|
||||
}
|
||||
|
||||
public class ServiceInfo
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Image { get; set; } = string.Empty;
|
||||
public string Replicas { get; set; } = string.Empty;
|
||||
}
|
||||
19
OTSSignsOrchestrator.Core/Services/IDockerSecretsService.cs
Normal file
19
OTSSignsOrchestrator.Core/Services/IDockerSecretsService.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for Docker Swarm secret operations.
|
||||
/// Implementations may use Docker.DotNet, local CLI, or SSH-based remote execution.
|
||||
/// </summary>
|
||||
public interface IDockerSecretsService
|
||||
{
|
||||
Task<(bool Created, string SecretId)> EnsureSecretAsync(string name, string value, bool rotate = false);
|
||||
Task<List<SecretListItem>> ListSecretsAsync();
|
||||
Task<bool> DeleteSecretAsync(string name);
|
||||
}
|
||||
|
||||
public class SecretListItem
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
536
OTSSignsOrchestrator.Core/Services/InstanceService.cs
Normal file
536
OTSSignsOrchestrator.Core/Services/InstanceService.cs
Normal file
@@ -0,0 +1,536 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
|
||||
/// New‐instance flow:
|
||||
/// 1. Clone template repo to local cache
|
||||
/// 2. Generate MySQL password → create Docker Swarm secret (never persisted locally)
|
||||
/// 3. Create MySQL database + user on external MySQL server via SSH
|
||||
/// 4. Render combined compose YAML (no MySQL container, CIFS volumes, Newt service)
|
||||
/// 5. Deploy stack via SSH
|
||||
/// </summary>
|
||||
public class InstanceService
|
||||
{
|
||||
private readonly XiboContext _db;
|
||||
private readonly GitTemplateService _git;
|
||||
private readonly ComposeRenderService _compose;
|
||||
private readonly ComposeValidationService _validation;
|
||||
private readonly IDockerCliService _docker;
|
||||
private readonly IDockerSecretsService _secrets;
|
||||
private readonly XiboApiService _xibo;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly DockerOptions _dockerOptions;
|
||||
private readonly ILogger<InstanceService> _logger;
|
||||
|
||||
public InstanceService(
|
||||
XiboContext db,
|
||||
GitTemplateService git,
|
||||
ComposeRenderService compose,
|
||||
ComposeValidationService validation,
|
||||
IDockerCliService docker,
|
||||
IDockerSecretsService secrets,
|
||||
XiboApiService xibo,
|
||||
SettingsService settings,
|
||||
IOptions<DockerOptions> dockerOptions,
|
||||
ILogger<InstanceService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_git = git;
|
||||
_compose = compose;
|
||||
_validation = validation;
|
||||
_docker = docker;
|
||||
_secrets = secrets;
|
||||
_xibo = xibo;
|
||||
_settings = settings;
|
||||
_dockerOptions = dockerOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new CMS instance:
|
||||
/// 1. Clone repo 2. Generate secrets 3. Create MySQL DB/user 4. Render compose 5. Deploy
|
||||
/// </summary>
|
||||
public async Task<DeploymentResultDto> CreateInstanceAsync(CreateInstanceDto dto, string? userId = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var opLog = StartOperation(OperationType.Create, userId);
|
||||
var abbrev = dto.CustomerAbbrev.Trim().ToLowerInvariant();
|
||||
var stackName = $"{abbrev}-cms-stack";
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
|
||||
|
||||
// ── Check uniqueness ────────────────────────────────────────────
|
||||
var existing = await _db.CmsInstances.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
|
||||
if (existing != null)
|
||||
throw new InvalidOperationException($"Stack '{stackName}' already exists.");
|
||||
|
||||
// ── 1. Clone template repo (optional) ───────────────────────────
|
||||
var repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
|
||||
var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(repoUrl))
|
||||
{
|
||||
_logger.LogInformation("Cloning template repo: {RepoUrl}", repoUrl);
|
||||
await _git.FetchAsync(repoUrl, repoPat);
|
||||
}
|
||||
|
||||
// ── 2. Generate MySQL password → Docker Swarm secret ────────────
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
var mysqlSecretName = $"{abbrev}-cms-db-password";
|
||||
await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
|
||||
await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName);
|
||||
_logger.LogInformation("Docker secret created: {SecretName}", mysqlSecretName);
|
||||
|
||||
// ── 3. Read settings ────────────────────────────────────────────
|
||||
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
|
||||
var themePath = (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var smtpServer = await _settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||
var smtpUsername = await _settings.GetAsync(SettingsService.SmtpUsername, string.Empty);
|
||||
var smtpPassword = await _settings.GetAsync(SettingsService.SmtpPassword, string.Empty);
|
||||
var smtpUseTls = await _settings.GetAsync(SettingsService.SmtpUseTls, "YES");
|
||||
var smtpUseStartTls = await _settings.GetAsync(SettingsService.SmtpUseStartTls, "YES");
|
||||
var smtpRewriteDomain = await _settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
|
||||
var smtpHostname = await _settings.GetAsync(SettingsService.SmtpHostname, string.Empty);
|
||||
var smtpFromLineOverride = await _settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
|
||||
|
||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
var cifsServer = dto.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
|
||||
var cifsShareBasePath = dto.CifsShareBasePath ?? await _settings.GetAsync(SettingsService.CifsShareBasePath);
|
||||
var cifsUsername = dto.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
|
||||
var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
|
||||
var cifsOptions = dto.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
|
||||
var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
var memcachedImage = await _settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||
var quickChartImage = await _settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||
|
||||
var phpPostMaxSize = await _settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
|
||||
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
// ── 4. Render compose YAML ──────────────────────────────────────
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = 80,
|
||||
CmsImage = cmsImage,
|
||||
MemcachedImage = memcachedImage,
|
||||
QuickChartImage = quickChartImage,
|
||||
NewtImage = newtImage,
|
||||
ThemeHostPath = themePath,
|
||||
MySqlHost = mySqlHost,
|
||||
MySqlPort = mySqlPort,
|
||||
MySqlDatabase = mySqlDbName,
|
||||
MySqlUser = mySqlUser,
|
||||
SmtpServer = smtpServer,
|
||||
SmtpUsername = smtpUsername,
|
||||
SmtpPassword = smtpPassword,
|
||||
SmtpUseTls = smtpUseTls,
|
||||
SmtpUseStartTls = smtpUseStartTls,
|
||||
SmtpRewriteDomain = smtpRewriteDomain,
|
||||
SmtpHostname = smtpHostname,
|
||||
SmtpFromLineOverride = smtpFromLineOverride,
|
||||
PhpPostMaxSize = phpPostMaxSize,
|
||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||
IncludeNewt = true,
|
||||
PangolinEndpoint = pangolinEndpoint,
|
||||
NewtId = dto.NewtId,
|
||||
NewtSecret = dto.NewtSecret,
|
||||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
var validationResult = _validation.Validate(composeYaml, abbrev);
|
||||
if (!validationResult.IsValid)
|
||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// ── 5. Deploy stack ─────────────────────────────────────────────
|
||||
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
||||
|
||||
// ── 6. Record instance ──────────────────────────────────────────
|
||||
var instance = new CmsInstance
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = 80,
|
||||
ThemeHostPath = themePath,
|
||||
LibraryHostPath = $"{abbrev}-cms-library",
|
||||
SmtpServer = smtpServer,
|
||||
SmtpUsername = smtpUsername,
|
||||
TemplateRepoUrl = repoUrl ?? string.Empty,
|
||||
TemplateRepoPat = repoPat,
|
||||
Status = InstanceStatus.Active,
|
||||
SshHostId = dto.SshHostId,
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
};
|
||||
|
||||
_db.CmsInstances.Add(instance);
|
||||
sw.Stop();
|
||||
opLog.InstanceId = instance.Id;
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance deployed: {stackName}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
|
||||
stackName, instance.Id, sw.ElapsedMilliseconds);
|
||||
|
||||
deployResult.ServiceCount = renderCtx.IncludeNewt ? 4 : 3;
|
||||
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: {StackName}", stackName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates MySQL database and user on external MySQL server via SSH.
|
||||
/// Called by the ViewModel before CreateInstanceAsync since it needs SSH access.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
|
||||
string abbrev,
|
||||
string mysqlPassword,
|
||||
Func<string, Task<(int ExitCode, string Stdout, string Stderr)>> runSshCommand)
|
||||
{
|
||||
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
var mySqlAdminUser = await _settings.GetAsync(SettingsService.MySqlAdminUser, "root");
|
||||
var mySqlAdminPassword = await _settings.GetAsync(SettingsService.MySqlAdminPassword, string.Empty);
|
||||
|
||||
var dbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var safePwd = mySqlAdminPassword.Replace("'", "'\\''");
|
||||
var safeUserPwd = mysqlPassword.Replace("'", "'\\''");
|
||||
|
||||
var sql = $"CREATE DATABASE IF NOT EXISTS \\`{dbName}\\`; "
|
||||
+ $"CREATE USER IF NOT EXISTS '{userName}'@'%' IDENTIFIED BY '{safeUserPwd}'; "
|
||||
+ $"GRANT ALL PRIVILEGES ON \\`{dbName}\\`.* TO '{userName}'@'%'; "
|
||||
+ $"FLUSH PRIVILEGES;";
|
||||
|
||||
var cmd = $"mysql -h {mySqlHost} -P {mySqlPort} -u {mySqlAdminUser} -p'{safePwd}' -e \"{sql}\" 2>&1";
|
||||
|
||||
_logger.LogInformation("Creating MySQL database {Db} and user {User}", dbName, userName);
|
||||
|
||||
var (exitCode, stdout, stderr) = await runSshCommand(cmd);
|
||||
|
||||
if (exitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("MySQL database and user created: {Db} / {User}", dbName, userName);
|
||||
return (true, $"Database '{dbName}' and user '{userName}' created.");
|
||||
}
|
||||
|
||||
var error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
|
||||
_logger.LogError("MySQL setup failed: {Error}", error);
|
||||
return (false, $"MySQL setup failed: {error.Trim()}");
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var opLog = StartOperation(OperationType.Update, userId);
|
||||
|
||||
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);
|
||||
|
||||
if (dto.TemplateRepoUrl != null) instance.TemplateRepoUrl = dto.TemplateRepoUrl;
|
||||
if (dto.TemplateRepoPat != null) instance.TemplateRepoPat = dto.TemplateRepoPat;
|
||||
if (dto.SmtpServer != null) instance.SmtpServer = dto.SmtpServer;
|
||||
if (dto.SmtpUsername != null) instance.SmtpUsername = dto.SmtpUsername;
|
||||
if (dto.Constraints != null) instance.Constraints = JsonSerializer.Serialize(dto.Constraints);
|
||||
if (dto.XiboUsername != null) instance.XiboUsername = dto.XiboUsername;
|
||||
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
|
||||
if (dto.CifsServer != null) instance.CifsServer = dto.CifsServer;
|
||||
if (dto.CifsShareBasePath != null) instance.CifsShareBasePath = dto.CifsShareBasePath;
|
||||
if (dto.CifsUsername != null) instance.CifsUsername = dto.CifsUsername;
|
||||
if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword;
|
||||
if (dto.CifsExtraOptions != null) instance.CifsExtraOptions = dto.CifsExtraOptions;
|
||||
|
||||
var abbrev = instance.CustomerAbbrev;
|
||||
var mysqlSecretName = $"{abbrev}-cms-db-password";
|
||||
|
||||
// Read current settings for re-render
|
||||
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var smtpServer = instance.SmtpServer;
|
||||
var smtpUsername = instance.SmtpUsername;
|
||||
var smtpPassword = await _settings.GetAsync(SettingsService.SmtpPassword, string.Empty);
|
||||
var smtpUseTls = await _settings.GetAsync(SettingsService.SmtpUseTls, "YES");
|
||||
var smtpUseStartTls = await _settings.GetAsync(SettingsService.SmtpUseStartTls, "YES");
|
||||
var smtpRewriteDomain = await _settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
|
||||
var smtpHostname = await _settings.GetAsync(SettingsService.SmtpHostname, string.Empty);
|
||||
var smtpFromLineOverride = await _settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
|
||||
|
||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
// Use per-instance CIFS credentials
|
||||
var cifsServer = instance.CifsServer;
|
||||
var cifsShareBasePath = instance.CifsShareBasePath;
|
||||
var cifsUsername = instance.CifsUsername;
|
||||
var cifsPassword = instance.CifsPassword;
|
||||
var cifsOptions = instance.CifsExtraOptions ?? "file_mode=0777,dir_mode=0777";
|
||||
|
||||
var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
var memcachedImage = await _settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||
var quickChartImage = await _settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||
|
||||
var phpPostMaxSize = await _settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
|
||||
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = instance.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = instance.StackName,
|
||||
CmsServerName = instance.CmsServerName,
|
||||
HostHttpPort = instance.HostHttpPort,
|
||||
CmsImage = cmsImage,
|
||||
MemcachedImage = memcachedImage,
|
||||
QuickChartImage = quickChartImage,
|
||||
NewtImage = newtImage,
|
||||
ThemeHostPath = instance.ThemeHostPath,
|
||||
MySqlHost = mySqlHost,
|
||||
MySqlPort = mySqlPort,
|
||||
MySqlDatabase = mySqlDbName,
|
||||
MySqlUser = mySqlUser,
|
||||
SmtpServer = smtpServer,
|
||||
SmtpUsername = smtpUsername,
|
||||
SmtpPassword = smtpPassword,
|
||||
SmtpUseTls = smtpUseTls,
|
||||
SmtpUseStartTls = smtpUseStartTls,
|
||||
SmtpRewriteDomain = smtpRewriteDomain,
|
||||
SmtpHostname = smtpHostname,
|
||||
SmtpFromLineOverride = smtpFromLineOverride,
|
||||
PhpPostMaxSize = phpPostMaxSize,
|
||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||
IncludeNewt = true,
|
||||
PangolinEndpoint = pangolinEndpoint,
|
||||
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
|
||||
CifsServer = cifsServer,
|
||||
CifsShareBasePath = cifsShareBasePath,
|
||||
CifsUsername = cifsUsername,
|
||||
CifsPassword = cifsPassword,
|
||||
CifsExtraOptions = cifsOptions,
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
var validationResult = _validation.Validate(composeYaml, abbrev);
|
||||
if (!validationResult.IsValid)
|
||||
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");
|
||||
|
||||
instance.UpdatedAt = DateTime.UtcNow;
|
||||
instance.Status = InstanceStatus.Active;
|
||||
|
||||
sw.Stop();
|
||||
opLog.InstanceId = instance.Id;
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance updated: {instance.StackName}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
deployResult.ServiceCount = 4;
|
||||
deployResult.Message = "Instance updated and redeployed.";
|
||||
return deployResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
opLog.Status = OperationStatus.Failure;
|
||||
opLog.Message = $"Update failed: {ex.Message}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
_logger.LogError(ex, "Instance update failed (id={Id})", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> DeleteInstanceAsync(
|
||||
Guid id, bool retainSecrets = false, bool clearXiboCreds = true, string? userId = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var opLog = StartOperation(OperationType.Delete, userId);
|
||||
|
||||
try
|
||||
{
|
||||
var instance = await _db.CmsInstances.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Instance {id} not found.");
|
||||
|
||||
_logger.LogInformation("Deleting instance: {StackName} (id={Id}) retainSecrets={RetainSecrets}",
|
||||
instance.StackName, id, retainSecrets);
|
||||
|
||||
var result = await _docker.RemoveStackAsync(instance.StackName);
|
||||
|
||||
if (!retainSecrets)
|
||||
{
|
||||
var mysqlSecretName = $"{instance.CustomerAbbrev}-cms-db-password";
|
||||
await _secrets.DeleteSecretAsync(mysqlSecretName);
|
||||
var secretMeta = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == mysqlSecretName);
|
||||
if (secretMeta != null)
|
||||
_db.SecretMetadata.Remove(secretMeta);
|
||||
}
|
||||
|
||||
instance.Status = InstanceStatus.Deleted;
|
||||
instance.DeletedAt = DateTime.UtcNow;
|
||||
instance.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (clearXiboCreds)
|
||||
{
|
||||
instance.XiboUsername = null;
|
||||
instance.XiboPassword = null;
|
||||
instance.XiboApiTestStatus = XiboApiTestStatus.Unknown;
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
opLog.InstanceId = instance.Id;
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance deleted: {instance.StackName}";
|
||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
result.Message = "Instance deleted.";
|
||||
return result;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CmsInstance?> GetInstanceAsync(Guid id)
|
||||
=> await _db.CmsInstances.Include(i => i.SshHost).FirstOrDefaultAsync(i => i.Id == id);
|
||||
|
||||
public async Task<(List<CmsInstance> Items, int TotalCount)> ListInstancesAsync(
|
||||
int page = 1, int pageSize = 50, string? filter = null)
|
||||
{
|
||||
var query = _db.CmsInstances.Include(i => i.SshHost).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
query = query.Where(i => i.CustomerName.Contains(filter) || i.StackName.Contains(filter));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query.OrderByDescending(i => i.CreatedAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<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)
|
||||
=> new OperationLog { Operation = type, UserId = userId, Status = OperationStatus.Pending };
|
||||
|
||||
private static string GenerateRandomPassword(int length)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||
return RandomNumberGenerator.GetString(chars, length);
|
||||
}
|
||||
}
|
||||
147
OTSSignsOrchestrator.Core/Services/SettingsService.cs
Normal file
147
OTSSignsOrchestrator.Core/Services/SettingsService.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes typed application settings from the AppSetting table.
|
||||
/// Sensitive values are encrypted/decrypted transparently via DataProtection.
|
||||
/// </summary>
|
||||
public class SettingsService
|
||||
{
|
||||
private readonly XiboContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly ILogger<SettingsService> _logger;
|
||||
|
||||
// ── Category constants ─────────────────────────────────────────────────
|
||||
public const string CatGit = "Git";
|
||||
public const string CatMySql = "MySql";
|
||||
public const string CatSmtp = "Smtp";
|
||||
public const string CatPangolin = "Pangolin";
|
||||
public const string CatCifs = "Cifs";
|
||||
public const string CatDefaults = "Defaults";
|
||||
|
||||
// ── Key constants ──────────────────────────────────────────────────────
|
||||
// Git
|
||||
public const string GitRepoUrl = "Git.RepoUrl";
|
||||
public const string GitRepoPat = "Git.RepoPat";
|
||||
|
||||
// MySQL Admin
|
||||
public const string MySqlHost = "MySql.Host";
|
||||
public const string MySqlPort = "MySql.Port";
|
||||
public const string MySqlAdminUser = "MySql.AdminUser";
|
||||
public const string MySqlAdminPassword = "MySql.AdminPassword";
|
||||
|
||||
// SMTP
|
||||
public const string SmtpServer = "Smtp.Server";
|
||||
public const string SmtpPort = "Smtp.Port";
|
||||
public const string SmtpUsername = "Smtp.Username";
|
||||
public const string SmtpPassword = "Smtp.Password";
|
||||
public const string SmtpUseTls = "Smtp.UseTls";
|
||||
public const string SmtpUseStartTls = "Smtp.UseStartTls";
|
||||
public const string SmtpRewriteDomain = "Smtp.RewriteDomain";
|
||||
public const string SmtpHostname = "Smtp.Hostname";
|
||||
public const string SmtpFromLineOverride = "Smtp.FromLineOverride";
|
||||
|
||||
// Pangolin
|
||||
public const string PangolinEndpoint = "Pangolin.Endpoint";
|
||||
|
||||
// CIFS
|
||||
public const string CifsServer = "Cifs.Server";
|
||||
public const string CifsShareBasePath = "Cifs.ShareBasePath";
|
||||
public const string CifsUsername = "Cifs.Username";
|
||||
public const string CifsPassword = "Cifs.Password";
|
||||
public const string CifsOptions = "Cifs.Options";
|
||||
|
||||
// Instance Defaults
|
||||
public const string DefaultCmsImage = "Defaults.CmsImage";
|
||||
public const string DefaultNewtImage = "Defaults.NewtImage";
|
||||
public const string DefaultMemcachedImage = "Defaults.MemcachedImage";
|
||||
public const string DefaultQuickChartImage = "Defaults.QuickChartImage";
|
||||
public const string DefaultCmsServerNameTemplate = "Defaults.CmsServerNameTemplate";
|
||||
public const string DefaultThemeHostPath = "Defaults.ThemeHostPath";
|
||||
public const string DefaultMySqlDbTemplate = "Defaults.MySqlDbTemplate";
|
||||
public const string DefaultMySqlUserTemplate = "Defaults.MySqlUserTemplate";
|
||||
public const string DefaultPhpPostMaxSize = "Defaults.PhpPostMaxSize";
|
||||
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
||||
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
||||
|
||||
public SettingsService(
|
||||
XiboContext db,
|
||||
IDataProtectionProvider dataProtection,
|
||||
ILogger<SettingsService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_protector = dataProtection.CreateProtector("OTSSignsOrchestrator.Settings");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Get a single setting value, decrypting if sensitive.</summary>
|
||||
public async Task<string?> GetAsync(string key)
|
||||
{
|
||||
var setting = await _db.AppSettings.FindAsync(key);
|
||||
if (setting == null) return null;
|
||||
return setting.IsSensitive && setting.Value != null
|
||||
? Unprotect(setting.Value)
|
||||
: setting.Value;
|
||||
}
|
||||
|
||||
/// <summary>Get a setting with a fallback default.</summary>
|
||||
public async Task<string> GetAsync(string key, string defaultValue)
|
||||
=> await GetAsync(key) ?? defaultValue;
|
||||
|
||||
/// <summary>Set a single setting, encrypting if sensitive.</summary>
|
||||
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
|
||||
{
|
||||
var setting = await _db.AppSettings.FindAsync(key);
|
||||
if (setting == null)
|
||||
{
|
||||
setting = new AppSetting { Key = key, Category = category, IsSensitive = isSensitive };
|
||||
_db.AppSettings.Add(setting);
|
||||
}
|
||||
|
||||
setting.Value = isSensitive && value != null ? _protector.Protect(value) : value;
|
||||
setting.IsSensitive = isSensitive;
|
||||
setting.Category = category;
|
||||
setting.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Save multiple settings in a single transaction.</summary>
|
||||
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
|
||||
{
|
||||
foreach (var (key, value, category, isSensitive) in settings)
|
||||
await SetAsync(key, value, category, isSensitive);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
_logger.LogInformation("Saved {Count} setting(s)",
|
||||
settings is ICollection<(string, string?, string, bool)> c ? c.Count : -1);
|
||||
}
|
||||
|
||||
/// <summary>Get all settings in a category (values decrypted).</summary>
|
||||
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
|
||||
{
|
||||
var settings = await _db.AppSettings
|
||||
.Where(s => s.Category == category)
|
||||
.ToListAsync();
|
||||
|
||||
return settings.ToDictionary(
|
||||
s => s.Key,
|
||||
s => s.IsSensitive && s.Value != null ? Unprotect(s.Value) : s.Value);
|
||||
}
|
||||
|
||||
private string? Unprotect(string protectedValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _protector.Unprotect(protectedValue);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to unprotect setting value — returning null");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
OTSSignsOrchestrator.Core/Services/XiboApiService.cs
Normal file
90
OTSSignsOrchestrator.Core/Services/XiboApiService.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests connectivity to deployed Xibo CMS instances using OAuth2.
|
||||
/// </summary>
|
||||
public class XiboApiService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly XiboOptions _options;
|
||||
private readonly ILogger<XiboApiService> _logger;
|
||||
|
||||
public XiboApiService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<XiboOptions> options,
|
||||
ILogger<XiboApiService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string username, string password)
|
||||
{
|
||||
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
||||
|
||||
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||
client.Timeout = TimeSpan.FromSeconds(_options.TestConnectionTimeoutSeconds);
|
||||
|
||||
try
|
||||
{
|
||||
var baseUrl = instanceUrl.TrimEnd('/');
|
||||
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
||||
|
||||
var formContent = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||
new KeyValuePair<string, string>("client_id", username),
|
||||
new KeyValuePair<string, string>("client_secret", password)
|
||||
});
|
||||
|
||||
var response = await client.PostAsync(tokenUrl, formContent);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
|
||||
return new XiboTestResult
|
||||
{
|
||||
IsValid = true,
|
||||
Message = "Connected successfully.",
|
||||
HttpStatus = (int)response.StatusCode
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
|
||||
instanceUrl, (int)response.StatusCode);
|
||||
|
||||
return new XiboTestResult
|
||||
{
|
||||
IsValid = false,
|
||||
Message = response.StatusCode switch
|
||||
{
|
||||
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
|
||||
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
|
||||
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
|
||||
_ => $"Unexpected response: {(int)response.StatusCode}"
|
||||
},
|
||||
HttpStatus = (int)response.StatusCode
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return new XiboTestResult { IsValid = false, Message = "Connection timed out." };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class XiboTestResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int HttpStatus { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user