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:
55
.gitea/workflows/docker-publish.yml
Normal file
55
.gitea/workflows/docker-publish.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Gitea Actions workflow: build Docker image and push to a container registry
|
||||
# Place secrets in the repository settings: REGISTRY (host[:port]), IMAGE_NAME, DOCKER_USERNAME, DOCKER_PASSWORD
|
||||
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# Use an appropriate runner that has Docker available (self-hosted runner)
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push image
|
||||
# run everything in a single shell step to keep tag calculation simple
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REGISTRY="${{ secrets.REGISTRY }}"
|
||||
IMAGE_NAME="${{ secrets.IMAGE_NAME }}"
|
||||
DOCKER_USERNAME="${{ secrets.DOCKER_USERNAME }}"
|
||||
DOCKER_PASSWORD="${{ secrets.DOCKER_PASSWORD }}"
|
||||
|
||||
if [ -z "$REGISTRY" ] || [ -z "$IMAGE_NAME" ]; then
|
||||
echo "Missing required secrets: REGISTRY and IMAGE_NAME must be set." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG=$(git rev-parse --short HEAD)
|
||||
IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
|
||||
LATEST="$REGISTRY/$IMAGE_NAME:latest"
|
||||
|
||||
echo "Logging in to $REGISTRY"
|
||||
echo "$DOCKER_PASSWORD" | docker login "$REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
|
||||
|
||||
echo "Building $IMAGE (and tagging as latest)"
|
||||
docker build -t "$IMAGE" -t "$LATEST" .
|
||||
|
||||
echo "Pushing $IMAGE"
|
||||
docker push "$IMAGE"
|
||||
|
||||
echo "Pushing $LATEST"
|
||||
docker push "$LATEST"
|
||||
|
||||
env:
|
||||
# secrets are available via ${{ secrets.<name> }} in Gitea Actions
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
IMAGE_NAME: ${{ secrets.IMAGE_NAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace OTSSignsOrchestrator.Configuration;
|
||||
namespace OTSSignsOrchestrator.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Shared constants for the application.
|
||||
@@ -8,10 +8,6 @@ public static class AppConstants
|
||||
public const string AdminRole = "Admin";
|
||||
public const string ViewerRole = "Viewer";
|
||||
|
||||
public const string AdminTokenScheme = "AdminToken";
|
||||
public const string OidcScheme = "OpenIdConnect";
|
||||
public const string CookieScheme = "Cookies";
|
||||
|
||||
/// <summary>Docker secret name for the global SMTP password.</summary>
|
||||
public const string GlobalSmtpSecretName = "global_smtp_password";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using OTSSignsOrchestrator.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Data;
|
||||
namespace OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
public class XiboContext : DbContext
|
||||
{
|
||||
@@ -16,9 +16,10 @@ public class XiboContext : DbContext
|
||||
}
|
||||
|
||||
public DbSet<CmsInstance> CmsInstances => Set<CmsInstance>();
|
||||
public DbSet<OidcProvider> OidcProviders => Set<OidcProvider>();
|
||||
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)
|
||||
{
|
||||
@@ -29,11 +30,13 @@ public class XiboContext : DbContext
|
||||
{
|
||||
entity.HasIndex(e => e.StackName).IsUnique();
|
||||
entity.HasIndex(e => e.CustomerName);
|
||||
|
||||
// Query filter for soft deletes
|
||||
entity.HasQueryFilter(e => e.DeletedAt == null);
|
||||
|
||||
// Encrypt sensitive fields if DataProtection is available
|
||||
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");
|
||||
@@ -44,22 +47,27 @@ public class XiboContext : DbContext
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// --- OidcProvider ---
|
||||
modelBuilder.Entity<OidcProvider>(entity =>
|
||||
// --- SshHost ---
|
||||
modelBuilder.Entity<SshHost>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.Name).IsUnique();
|
||||
entity.HasIndex(e => e.Label).IsUnique();
|
||||
|
||||
if (_dataProtection != null)
|
||||
{
|
||||
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.OidcProvider");
|
||||
var secretConverter = new ValueConverter<string, string>(
|
||||
v => protector.Protect(v),
|
||||
v => protector.Unprotect(v));
|
||||
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.ClientSecret).HasConversion(secretConverter);
|
||||
entity.Property(e => e.KeyPassphrase).HasConversion(passphraseConverter);
|
||||
entity.Property(e => e.Password).HasConversion(passwordConverter);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,5 +84,12 @@ public class XiboContext : DbContext
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,15 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Migrations
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
[DbContext(typeof(XiboContext))]
|
||||
[Migration("20260212185423_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
[Migration("20260217004115_DesktopInitial")]
|
||||
partial class DesktopInitial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -20,7 +20,7 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -64,6 +64,9 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SshHostId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -114,63 +117,15 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
|
||||
b.HasIndex("CustomerName");
|
||||
|
||||
b.HasIndex("SshHostId");
|
||||
|
||||
b.HasIndex("StackName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CmsInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OidcProvider", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Audience")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Authority")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientSecret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPrimary")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("OidcProviders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OperationLog", b =>
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -182,10 +137,6 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
b.Property<Guid?>("InstanceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -193,9 +144,6 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("ProviderId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -212,14 +160,12 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
|
||||
b.HasIndex("Operation");
|
||||
|
||||
b.HasIndex("ProviderId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.ToTable("OperationLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.SecretMetadata", b =>
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -251,25 +197,93 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
b.ToTable("SecretMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OperationLog", b =>
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||
{
|
||||
b.HasOne("XiboSwarmAdmin.Models.Entities.CmsInstance", "Instance")
|
||||
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.HasOne("XiboSwarmAdmin.Models.Entities.OidcProvider", "Provider")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderId");
|
||||
|
||||
b.Navigation("Instance");
|
||||
|
||||
b.Navigation("Provider");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,53 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Migrations
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
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
|
||||
@@ -36,47 +75,18 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
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)
|
||||
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);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OidcProviders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
Authority = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
ClientId = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
ClientSecret = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: false),
|
||||
Audience = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
|
||||
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
IsPrimary = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OidcProviders", x => x.Id);
|
||||
});
|
||||
|
||||
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);
|
||||
table.ForeignKey(
|
||||
name: "FK_CmsInstances_SshHosts_SshHostId",
|
||||
column: x => x.SshHostId,
|
||||
principalTable: "SshHosts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
@@ -86,13 +96,11 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Operation = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
InstanceId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
ProviderId = 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),
|
||||
IpAddress = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true)
|
||||
Timestamp = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
@@ -102,11 +110,6 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "CmsInstances",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_OperationLogs_OidcProviders_ProviderId",
|
||||
column: x => x.ProviderId,
|
||||
principalTable: "OidcProviders",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
@@ -114,18 +117,17 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
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_OidcProviders_Name",
|
||||
table: "OidcProviders",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OperationLogs_InstanceId",
|
||||
table: "OperationLogs",
|
||||
@@ -136,11 +138,6 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
table: "OperationLogs",
|
||||
column: "Operation");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OperationLogs_ProviderId",
|
||||
table: "OperationLogs",
|
||||
column: "ProviderId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OperationLogs_Timestamp",
|
||||
table: "OperationLogs",
|
||||
@@ -151,6 +148,12 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
table: "SecretMetadata",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SshHosts_Label",
|
||||
table: "SshHosts",
|
||||
column: "Label",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -166,7 +169,7 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
name: "CmsInstances");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OidcProviders");
|
||||
name: "SshHosts");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,25 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Migrations
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
[DbContext(typeof(XiboContext))]
|
||||
partial class XiboContextModelSnapshot : ModelSnapshot
|
||||
[Migration("20260218140239_AddCustomerAbbrev")]
|
||||
partial class AddCustomerAbbrev
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -35,6 +38,11 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerAbbrev")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -61,6 +69,9 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SshHostId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -111,63 +122,15 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
|
||||
b.HasIndex("CustomerName");
|
||||
|
||||
b.HasIndex("SshHostId");
|
||||
|
||||
b.HasIndex("StackName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CmsInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OidcProvider", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Audience")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Authority")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientSecret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPrimary")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("OidcProviders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OperationLog", b =>
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -179,10 +142,6 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
b.Property<Guid?>("InstanceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -190,9 +149,6 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("ProviderId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -209,14 +165,12 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
|
||||
b.HasIndex("Operation");
|
||||
|
||||
b.HasIndex("ProviderId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.ToTable("OperationLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.SecretMetadata", b =>
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -248,25 +202,93 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
b.ToTable("SecretMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OperationLog", b =>
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||
{
|
||||
b.HasOne("XiboSwarmAdmin.Models.Entities.CmsInstance", "Instance")
|
||||
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.HasOne("XiboSwarmAdmin.Models.Entities.OidcProvider", "Provider")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderId");
|
||||
|
||||
b.Navigation("Instance");
|
||||
|
||||
b.Navigation("Provider");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
|
||||
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; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace OTSSignsOrchestrator.Models.DTOs;
|
||||
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
public class DeploymentResultDto
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace OTSSignsOrchestrator.Models.DTOs;
|
||||
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
public class TemplateConfig
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OTSSignsOrchestrator.Models.DTOs;
|
||||
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
public class UpdateInstanceDto
|
||||
{
|
||||
@@ -23,4 +23,21 @@ public class UpdateInstanceDto
|
||||
|
||||
[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>();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OTSSignsOrchestrator.Models.Entities;
|
||||
namespace OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
public enum OperationType
|
||||
{
|
||||
@@ -33,26 +33,16 @@ public class OperationLog
|
||||
[ForeignKey(nameof(InstanceId))]
|
||||
public CmsInstance? Instance { get; set; }
|
||||
|
||||
public Guid? ProviderId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(ProviderId))]
|
||||
public OidcProvider? Provider { 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>
|
||||
/// <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;
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? IpAddress { get; set; }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OTSSignsOrchestrator.Models.Entities;
|
||||
namespace OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
public class SecretMetadata
|
||||
{
|
||||
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>();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
@@ -7,16 +7,18 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Docker.DotNet" Version="3.125.15" />
|
||||
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.2" />
|
||||
<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.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<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" />
|
||||
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;
|
||||
}
|
||||
@@ -1,34 +1,25 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
|
||||
namespace OTSSignsOrchestrator.Services;
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a rendered Compose YAML before deployment.
|
||||
/// Checks syntax, required structure, secrets references, and service presence.
|
||||
/// </summary>
|
||||
public class ComposeValidationService
|
||||
{
|
||||
private readonly ILogger<ComposeValidationService> _logger;
|
||||
|
||||
private static readonly HashSet<string> RequiredServices = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"cms-db", "cms-web", "cms-memcached", "cms-quickchart"
|
||||
};
|
||||
|
||||
public ComposeValidationService(ILogger<ComposeValidationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate a Compose YAML string; return errors (empty list = valid).
|
||||
/// </summary>
|
||||
public ValidationResult Validate(string composeYaml)
|
||||
public ValidationResult Validate(string composeYaml, string? customerAbbrev = null)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// 1. YAML syntax
|
||||
YamlStream yamlStream;
|
||||
try
|
||||
{
|
||||
@@ -55,13 +46,11 @@ public class ComposeValidationService
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
// 2. Required top-level keys
|
||||
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.");
|
||||
|
||||
// 3. Validate services
|
||||
if (HasKey(root, "services") && root.Children[new YamlScalarNode("services")] is YamlMappingNode services)
|
||||
{
|
||||
var presentServices = services.Children.Keys
|
||||
@@ -69,17 +58,27 @@ public class ComposeValidationService
|
||||
.Select(k => k.Value!)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var required in RequiredServices)
|
||||
// Determine required service suffixes
|
||||
var requiredSuffixes = new[] { "-web", "-memcached", "-quickchart" };
|
||||
var prefix = customerAbbrev ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(prefix))
|
||||
{
|
||||
if (!presentServices.Contains(required))
|
||||
errors.Add($"Missing required service: '{required}'.");
|
||||
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.");
|
||||
}
|
||||
|
||||
// Check that XMR is NOT present
|
||||
if (presentServices.Contains("cms-xmr"))
|
||||
warnings.Add("Service 'cms-xmr' is present but not needed for Xibo CMS 4.4.0.");
|
||||
|
||||
// Validate each service has an image
|
||||
foreach (var (key, value) in services.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyNode && value is YamlMappingNode svcNode)
|
||||
@@ -90,38 +89,28 @@ public class ComposeValidationService
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validate secrets section
|
||||
if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secrets)
|
||||
if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secretsNode)
|
||||
{
|
||||
foreach (var (key, value) in secrets.Children)
|
||||
foreach (var (key, value) in secretsNode.Children)
|
||||
{
|
||||
if (value is YamlMappingNode secretNode)
|
||||
{
|
||||
if (!HasKey(secretNode, "external"))
|
||||
warnings.Add($"Secret '{((YamlScalarNode)key).Value}' is not marked as 'external: true'.");
|
||||
warnings.Add($"Secret '{(key as YamlScalarNode)?.Value}' is not external.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Validate volumes section exists
|
||||
if (!HasKey(root, "volumes"))
|
||||
warnings.Add("Missing top-level key: 'volumes'. Named volumes may not be created.");
|
||||
|
||||
_logger.LogInformation("Compose validation: {ErrorCount} errors, {WarningCount} warnings",
|
||||
errors.Count, warnings.Count);
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
private static bool HasKey(YamlMappingNode node, string key)
|
||||
{
|
||||
return node.Children.ContainsKey(new YamlScalarNode(key));
|
||||
}
|
||||
=> node.Children.ContainsKey(new YamlScalarNode(key));
|
||||
}
|
||||
|
||||
public class ValidationResult
|
||||
{
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
public List<string> Errors { get; set; } = new();
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using LibGit2Sharp;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
|
||||
namespace OTSSignsOrchestrator.Services;
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches template.yml and template.env from a Git repository using LibGit2Sharp.
|
||||
@@ -22,10 +23,6 @@ public class GitTemplateService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch template.yml and template.env from a Git repo.
|
||||
/// Uses cached clone if fresh; shallow clones or fetches as needed.
|
||||
/// </summary>
|
||||
public async Task<TemplateConfig> FetchAsync(string repoUrl, string? pat = null, bool forceRefresh = false)
|
||||
{
|
||||
var cacheKey = ComputeCacheKey(repoUrl);
|
||||
@@ -79,20 +76,12 @@ public class GitTemplateService
|
||||
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var cloneOpts = new CloneOptions
|
||||
{
|
||||
IsBare = false,
|
||||
RecurseSubmodules = false
|
||||
};
|
||||
var cloneOpts = new CloneOptions { IsBare = false, RecurseSubmodules = false };
|
||||
|
||||
if (!string.IsNullOrEmpty(pat))
|
||||
{
|
||||
cloneOpts.FetchOptions.CredentialsProvider = (_, _, _) =>
|
||||
new UsernamePasswordCredentials
|
||||
{
|
||||
Username = pat,
|
||||
Password = string.Empty
|
||||
};
|
||||
new UsernamePasswordCredentials { Username = pat, Password = string.Empty };
|
||||
}
|
||||
|
||||
try
|
||||
@@ -118,25 +107,19 @@ public class GitTemplateService
|
||||
if (!string.IsNullOrEmpty(pat))
|
||||
{
|
||||
fetchOpts.CredentialsProvider = (_, _, _) =>
|
||||
new UsernamePasswordCredentials
|
||||
{
|
||||
Username = pat,
|
||||
Password = string.Empty
|
||||
};
|
||||
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");
|
||||
|
||||
// Fast-forward the default branch
|
||||
var trackingBranch = repo.Head.TrackedBranch;
|
||||
if (trackingBranch != null)
|
||||
{
|
||||
repo.Reset(ResetMode.Hard, trackingBranch.Tip);
|
||||
}
|
||||
|
||||
// Update cache timestamp
|
||||
WriteCacheTimestamp(cacheDir);
|
||||
}
|
||||
catch (LibGit2SharpException ex)
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
|
||||
namespace OTSSignsOrchestrator.Services;
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Communicates with deployed Xibo CMS instances via REST API.
|
||||
/// Tests connectivity and provides stubs for future management operations.
|
||||
/// NEVER logs passwords or credentials.
|
||||
/// Tests connectivity to deployed Xibo CMS instances using OAuth2.
|
||||
/// </summary>
|
||||
public class XiboApiService
|
||||
{
|
||||
@@ -27,10 +23,6 @@ public class XiboApiService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test connection to a Xibo CMS instance using provided credentials.
|
||||
/// Attempts OAuth2 client_credentials or resource-owner password grant.
|
||||
/// </summary>
|
||||
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string username, string password)
|
||||
{
|
||||
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
||||
@@ -40,7 +32,6 @@ public class XiboApiService
|
||||
|
||||
try
|
||||
{
|
||||
// Xibo CMS uses OAuth2. Try resource-owner password grant first.
|
||||
var baseUrl = instanceUrl.TrimEnd('/');
|
||||
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
||||
|
||||
@@ -64,7 +55,6 @@ public class XiboApiService
|
||||
};
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
|
||||
instanceUrl, (int)response.StatusCode);
|
||||
|
||||
@@ -83,45 +73,13 @@ public class XiboApiService
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Xibo connection test timed out: {InstanceUrl}", instanceUrl);
|
||||
return new XiboTestResult
|
||||
{
|
||||
IsValid = false,
|
||||
Message = "Connection timed out. Xibo instance may not be running.",
|
||||
HttpStatus = 0
|
||||
};
|
||||
return new XiboTestResult { IsValid = false, Message = "Connection timed out." };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Xibo connection test failed (network): {InstanceUrl}", instanceUrl);
|
||||
return new XiboTestResult
|
||||
{
|
||||
IsValid = false,
|
||||
Message = $"Cannot reach Xibo instance: {ex.Message}",
|
||||
HttpStatus = 0
|
||||
};
|
||||
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stubs for future management APIs ---
|
||||
|
||||
public Task<object?> GetLayoutsAsync(string instanceUrl, string accessToken)
|
||||
{
|
||||
_logger.LogDebug("GetLayouts stub called for {InstanceUrl}", instanceUrl);
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<object?> GetDisplaysAsync(string instanceUrl, string accessToken)
|
||||
{
|
||||
_logger.LogDebug("GetDisplays stub called for {InstanceUrl}", instanceUrl);
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<object?> GetSettingsAsync(string instanceUrl, string accessToken)
|
||||
{
|
||||
_logger.LogDebug("GetSettings stub called for {InstanceUrl}", instanceUrl);
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
public class XiboTestResult
|
||||
9
OTSSignsOrchestrator.Desktop/App.axaml
Normal file
9
OTSSignsOrchestrator.Desktop/App.axaml
Normal file
@@ -0,0 +1,9 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
144
OTSSignsOrchestrator.Desktop/App.axaml.cs
Normal file
144
OTSSignsOrchestrator.Desktop/App.axaml.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
using OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
using OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop;
|
||||
|
||||
public class App : Application
|
||||
{
|
||||
public static IServiceProvider Services { get; private set; } = null!;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
ConfigureServices(services);
|
||||
Services = services.BuildServiceProvider();
|
||||
|
||||
// Apply migrations
|
||||
using (var scope = Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("ApplicationLifetime type: {Type}", ApplicationLifetime?.GetType().FullName ?? "null");
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Log.Information("Creating MainWindow...");
|
||||
var vm = Services.GetRequiredService<MainWindowViewModel>();
|
||||
Log.Information("MainWindowViewModel resolved");
|
||||
|
||||
var window = new MainWindow
|
||||
{
|
||||
DataContext = vm
|
||||
};
|
||||
|
||||
desktop.MainWindow = window;
|
||||
Log.Information("MainWindow assigned to lifetime");
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
Log.Information("MainWindow Show() + Activate() called");
|
||||
|
||||
desktop.ShutdownRequested += (_, _) =>
|
||||
{
|
||||
var ssh = Services.GetService<SshConnectionService>();
|
||||
ssh?.Dispose();
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("ApplicationLifetime is NOT IClassicDesktopStyleApplicationLifetime — window cannot be shown");
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// Configuration
|
||||
var config = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
|
||||
// Options
|
||||
services.Configure<GitOptions>(config.GetSection(GitOptions.SectionName));
|
||||
services.Configure<DockerOptions>(config.GetSection(DockerOptions.SectionName));
|
||||
services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
||||
services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
|
||||
services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
|
||||
|
||||
// Logging
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.ClearProviders();
|
||||
builder.AddSerilog(dispose: true);
|
||||
});
|
||||
|
||||
// Data Protection
|
||||
var keysDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OTSSignsOrchestrator", "keys");
|
||||
Directory.CreateDirectory(keysDir);
|
||||
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(keysDir))
|
||||
.SetApplicationName("OTSSignsOrchestrator");
|
||||
|
||||
// Database
|
||||
var connStr = config.GetConnectionString("Default") ?? "Data Source=otssigns-desktop.db";
|
||||
services.AddDbContext<XiboContext>(options => options.UseSqlite(connStr));
|
||||
|
||||
// HTTP
|
||||
services.AddHttpClient();
|
||||
services.AddHttpClient("XiboApi");
|
||||
|
||||
// SSH services (singletons — maintain connections)
|
||||
services.AddSingleton<SshConnectionService>();
|
||||
|
||||
// Docker services via SSH (scoped so they get fresh per-operation context)
|
||||
services.AddTransient<SshDockerCliService>();
|
||||
services.AddTransient<SshDockerSecretsService>();
|
||||
services.AddTransient<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
|
||||
services.AddTransient<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
|
||||
|
||||
// Core services
|
||||
services.AddTransient<SettingsService>();
|
||||
services.AddTransient<GitTemplateService>();
|
||||
services.AddTransient<ComposeRenderService>();
|
||||
services.AddTransient<ComposeValidationService>();
|
||||
services.AddTransient<XiboApiService>();
|
||||
services.AddTransient<InstanceService>();
|
||||
|
||||
// ViewModels
|
||||
services.AddTransient<MainWindowViewModel>();
|
||||
services.AddTransient<HostsViewModel>();
|
||||
services.AddTransient<InstancesViewModel>();
|
||||
services.AddTransient<CreateInstanceViewModel>();
|
||||
services.AddTransient<SecretsViewModel>();
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
services.AddTransient<LogsViewModel>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<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.DependencyInjection" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
|
||||
<PackageReference Include="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="SSH.NET" Version="2024.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OTSSignsOrchestrator.Core\OTSSignsOrchestrator.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
40
OTSSignsOrchestrator.Desktop/Program.cs
Normal file
40
OTSSignsOrchestrator.Desktop/Program.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Avalonia;
|
||||
using Avalonia.ReactiveUI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Serilog;
|
||||
using System;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop;
|
||||
|
||||
sealed class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7)
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace()
|
||||
.UseReactiveUI();
|
||||
}
|
||||
186
OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs
Normal file
186
OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using Renci.SshNet;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages SSH connections to remote Docker Swarm hosts.
|
||||
/// Creates and caches SshClient instances with key or password authentication.
|
||||
/// </summary>
|
||||
public class SshConnectionService : IDisposable
|
||||
{
|
||||
private readonly ILogger<SshConnectionService> _logger;
|
||||
private readonly Dictionary<Guid, SshClient> _clients = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public SshConnectionService(ILogger<SshConnectionService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or create a connected SshClient for a given SshHost.
|
||||
/// </summary>
|
||||
public SshClient GetClient(SshHost host)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_clients.TryGetValue(host.Id, out var existing) && existing.IsConnected)
|
||||
return existing;
|
||||
|
||||
// Dispose old client if disconnected
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Dispose();
|
||||
_clients.Remove(host.Id);
|
||||
}
|
||||
|
||||
var client = CreateClient(host);
|
||||
client.Connect();
|
||||
_clients[host.Id] = client;
|
||||
|
||||
_logger.LogInformation("SSH connected to {Host}:{Port} as {User}", host.Host, host.Port, host.Username);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test the SSH connection to a host. Returns (success, message).
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string Message)> TestConnectionAsync(SshHost host)
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = CreateClient(host);
|
||||
client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(10);
|
||||
client.Connect();
|
||||
|
||||
if (client.IsConnected)
|
||||
{
|
||||
// Quick test: run a simple command
|
||||
using var cmd = client.RunCommand("docker --version");
|
||||
client.Disconnect();
|
||||
|
||||
if (cmd.ExitStatus == 0)
|
||||
return (true, $"Connected. {cmd.Result.Trim()}");
|
||||
else
|
||||
return (true, $"Connected but docker not available: {cmd.Error}");
|
||||
}
|
||||
|
||||
return (false, "Failed to connect.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SSH connection test failed for {Host}:{Port}", host.Host, host.Port);
|
||||
return (false, $"Connection failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run a command on the remote host and return (exitCode, stdout, stderr).
|
||||
/// </summary>
|
||||
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandAsync(SshHost host, string command)
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var client = GetClient(host);
|
||||
using var cmd = client.RunCommand(command);
|
||||
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run a command that requires stdin input (e.g., docker stack deploy --compose-file -).
|
||||
/// </summary>
|
||||
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandWithStdinAsync(
|
||||
SshHost host, string command, string stdinContent)
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var client = GetClient(host);
|
||||
|
||||
// Use shell stream approach for stdin piping
|
||||
// We pipe via: echo '<content>' | <command>
|
||||
// But for large YAML, use a heredoc approach
|
||||
var safeContent = stdinContent.Replace("'", "'\\''");
|
||||
var fullCommand = $"printf '%s' '{safeContent}' | {command}";
|
||||
|
||||
using var cmd = client.RunCommand(fullCommand);
|
||||
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnect and remove a cached client.
|
||||
/// </summary>
|
||||
public void Disconnect(Guid hostId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_clients.TryGetValue(hostId, out var client))
|
||||
{
|
||||
client.Disconnect();
|
||||
client.Dispose();
|
||||
_clients.Remove(hostId);
|
||||
_logger.LogInformation("SSH disconnected from host {HostId}", hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SshClient CreateClient(SshHost host)
|
||||
{
|
||||
var authMethods = new List<AuthenticationMethod>();
|
||||
|
||||
if (host.UseKeyAuth && !string.IsNullOrEmpty(host.PrivateKeyPath))
|
||||
{
|
||||
var keyFile = string.IsNullOrEmpty(host.KeyPassphrase)
|
||||
? new PrivateKeyFile(host.PrivateKeyPath)
|
||||
: new PrivateKeyFile(host.PrivateKeyPath, host.KeyPassphrase);
|
||||
|
||||
authMethods.Add(new PrivateKeyAuthenticationMethod(host.Username, keyFile));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(host.Password))
|
||||
{
|
||||
authMethods.Add(new PasswordAuthenticationMethod(host.Username, host.Password));
|
||||
}
|
||||
|
||||
if (authMethods.Count == 0)
|
||||
{
|
||||
// Fall back to default SSH agent / key in ~/.ssh/
|
||||
var defaultKeyPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
|
||||
|
||||
if (File.Exists(defaultKeyPath))
|
||||
{
|
||||
authMethods.Add(new PrivateKeyAuthenticationMethod(host.Username, new PrivateKeyFile(defaultKeyPath)));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No authentication method configured for SSH host '{host.Label}'. " +
|
||||
"Provide a private key path or password.");
|
||||
}
|
||||
}
|
||||
|
||||
var connInfo = new ConnectionInfo(host.Host, host.Port, host.Username, authMethods.ToArray());
|
||||
return new SshClient(connInfo);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var client in _clients.Values)
|
||||
{
|
||||
try { client.Disconnect(); } catch { }
|
||||
client.Dispose();
|
||||
}
|
||||
_clients.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
159
OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs
Normal file
159
OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Docker CLI service that executes docker commands on a remote host over SSH.
|
||||
/// Requires an SshHost to be set before use via SetHost().
|
||||
/// </summary>
|
||||
public class SshDockerCliService : IDockerCliService
|
||||
{
|
||||
private readonly SshConnectionService _ssh;
|
||||
private readonly DockerOptions _options;
|
||||
private readonly ILogger<SshDockerCliService> _logger;
|
||||
private SshHost? _currentHost;
|
||||
|
||||
public SshDockerCliService(
|
||||
SshConnectionService ssh,
|
||||
IOptions<DockerOptions> options,
|
||||
ILogger<SshDockerCliService> logger)
|
||||
{
|
||||
_ssh = ssh;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the SSH host to use for Docker commands.
|
||||
/// </summary>
|
||||
public void SetHost(SshHost host)
|
||||
{
|
||||
_currentHost = host;
|
||||
}
|
||||
|
||||
public SshHost? CurrentHost => _currentHost;
|
||||
|
||||
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
|
||||
{
|
||||
EnsureHost();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var args = "docker stack deploy --compose-file -";
|
||||
if (resolveImage)
|
||||
args += " --resolve-image changed";
|
||||
args += $" {stackName}";
|
||||
|
||||
_logger.LogInformation("Deploying stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandWithStdinAsync(_currentHost, args, composeYaml);
|
||||
sw.Stop();
|
||||
|
||||
var result = new DeploymentResultDto
|
||||
{
|
||||
StackName = stackName,
|
||||
Success = exitCode == 0,
|
||||
ExitCode = exitCode,
|
||||
Output = stdout,
|
||||
ErrorMessage = stderr,
|
||||
Message = exitCode == 0 ? "Success" : "Failed",
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
if (result.Success)
|
||||
_logger.LogInformation("Stack deployed via SSH: {StackName} | duration={DurationMs}ms", stackName, result.DurationMs);
|
||||
else
|
||||
_logger.LogError("Stack deploy failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> RemoveStackAsync(string stackName)
|
||||
{
|
||||
EnsureHost();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
_logger.LogInformation("Removing stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost, $"docker stack rm {stackName}");
|
||||
sw.Stop();
|
||||
|
||||
var result = new DeploymentResultDto
|
||||
{
|
||||
StackName = stackName,
|
||||
Success = exitCode == 0,
|
||||
ExitCode = exitCode,
|
||||
Output = stdout,
|
||||
ErrorMessage = stderr,
|
||||
Message = exitCode == 0 ? "Success" : "Failed",
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
if (result.Success)
|
||||
_logger.LogInformation("Stack removed via SSH: {StackName}", stackName);
|
||||
else
|
||||
_logger.LogError("Stack remove failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<StackInfo>> ListStacksAsync()
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, "docker stack ls --format '{{.Name}}\\t{{.Services}}'");
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
return new List<StackInfo>();
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 2);
|
||||
return new StackInfo
|
||||
{
|
||||
Name = parts[0].Trim(),
|
||||
ServiceCount = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var c) ? c : 0
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, $"docker stack services {stackName} --format '{{{{.Name}}}}\\t{{{{.Image}}}}\\t{{{{.Replicas}}}}'");
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
return new List<ServiceInfo>();
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 3);
|
||||
return new ServiceInfo
|
||||
{
|
||||
Name = parts.Length > 0 ? parts[0].Trim() : "",
|
||||
Image = parts.Length > 1 ? parts[1].Trim() : "",
|
||||
Replicas = parts.Length > 2 ? parts[2].Trim() : ""
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void EnsureHost()
|
||||
{
|
||||
if (_currentHost == null)
|
||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
||||
}
|
||||
}
|
||||
140
OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs
Normal file
140
OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Docker Swarm secrets management over SSH.
|
||||
/// Uses docker CLI commands executed remotely instead of Docker.DotNet.
|
||||
/// </summary>
|
||||
public class SshDockerSecretsService : IDockerSecretsService
|
||||
{
|
||||
private readonly SshConnectionService _ssh;
|
||||
private readonly ILogger<SshDockerSecretsService> _logger;
|
||||
private SshHost? _currentHost;
|
||||
|
||||
public SshDockerSecretsService(SshConnectionService ssh, ILogger<SshDockerSecretsService> logger)
|
||||
{
|
||||
_ssh = ssh;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void SetHost(SshHost host) => _currentHost = host;
|
||||
public SshHost? CurrentHost => _currentHost;
|
||||
|
||||
public async Task<(bool Created, string SecretId)> EnsureSecretAsync(string name, string value, bool rotate = false)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
_logger.LogInformation("Ensuring secret exists via SSH: {SecretName}", name);
|
||||
|
||||
// Check if secret already exists
|
||||
var existing = await FindSecretAsync(name);
|
||||
|
||||
if (existing != null && !rotate)
|
||||
{
|
||||
_logger.LogDebug("Secret already exists: {SecretName} (id={SecretId})", name, existing.Value.id);
|
||||
return (false, existing.Value.id);
|
||||
}
|
||||
|
||||
if (existing != null && rotate)
|
||||
{
|
||||
_logger.LogInformation("Rotating secret via SSH: {SecretName} (old id={SecretId})", name, existing.Value.id);
|
||||
await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}");
|
||||
}
|
||||
|
||||
// Create secret via stdin
|
||||
var safeValue = value.Replace("'", "'\\''");
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, $"printf '%s' '{safeValue}' | docker secret create {name} -");
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
_logger.LogError("Failed to create secret via SSH: {SecretName} | error={Error}", name, stderr);
|
||||
return (false, string.Empty);
|
||||
}
|
||||
|
||||
var secretId = stdout.Trim();
|
||||
_logger.LogInformation("Secret created via SSH: {SecretName} (id={SecretId})", name, secretId);
|
||||
return (true, secretId);
|
||||
}
|
||||
|
||||
public async Task<List<SecretListItem>> ListSecretsAsync()
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, "docker secret ls --format '{{.ID}}\\t{{.Name}}\\t{{.CreatedAt}}'");
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
return new List<SecretListItem>();
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 3);
|
||||
return new SecretListItem
|
||||
{
|
||||
Id = parts.Length > 0 ? parts[0].Trim() : "",
|
||||
Name = parts.Length > 1 ? parts[1].Trim() : "",
|
||||
CreatedAt = parts.Length > 2 && DateTime.TryParse(parts[2].Trim(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)
|
||||
? dt
|
||||
: DateTime.MinValue
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteSecretAsync(string name)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
var existing = await FindSecretAsync(name);
|
||||
if (existing == null)
|
||||
{
|
||||
_logger.LogDebug("Secret not found for deletion: {SecretName}", name);
|
||||
return true; // idempotent
|
||||
}
|
||||
|
||||
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}");
|
||||
if (exitCode != 0)
|
||||
{
|
||||
_logger.LogError("Failed to delete secret via SSH: {SecretName} | error={Error}", name, stderr);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Secret deleted via SSH: {SecretName}", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<(string id, string name)?> FindSecretAsync(string name)
|
||||
{
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, $"docker secret ls --filter 'name={name}' --format '{{{{.ID}}}}\\t{{{{.Name}}}}'");
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
return null;
|
||||
|
||||
var line = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault(l =>
|
||||
{
|
||||
var parts = l.Split('\t', 2);
|
||||
return parts.Length > 1 && string.Equals(parts[1].Trim(), name, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
|
||||
if (line == null) return null;
|
||||
|
||||
var p = line.Split('\t', 2);
|
||||
return (p[0].Trim(), p[1].Trim());
|
||||
}
|
||||
|
||||
private void EnsureHost()
|
||||
{
|
||||
if (_currentHost == null)
|
||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker secrets.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Security.Cryptography;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the Create Instance form.
|
||||
/// Simplified: Customer Name, Abbreviation, SSH Host, and optional Newt credentials.
|
||||
/// All other config comes from the Settings page.
|
||||
/// </summary>
|
||||
public partial class CreateInstanceViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _deployOutput = string.Empty;
|
||||
[ObservableProperty] private double _progressPercent;
|
||||
[ObservableProperty] private string _progressStep = string.Empty;
|
||||
|
||||
// Core form fields — only these two are required from the user
|
||||
[ObservableProperty] private string _customerName = string.Empty;
|
||||
[ObservableProperty] private string _customerAbbrev = string.Empty;
|
||||
|
||||
// Optional Pangolin/Newt credentials (per-instance)
|
||||
[ObservableProperty] private string _newtId = string.Empty;
|
||||
[ObservableProperty] private string _newtSecret = string.Empty;
|
||||
|
||||
// CIFS / SMB credentials (per-instance, defaults loaded from global settings)
|
||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
|
||||
[ObservableProperty] private string _cifsUsername = string.Empty;
|
||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
|
||||
|
||||
// SSH host selection
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
|
||||
// ── Derived preview properties ───────────────────────────────────────────
|
||||
|
||||
public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—";
|
||||
public string PreviewServiceWeb => Valid ? $"{Abbrev}-web" : "—";
|
||||
public string PreviewServiceCache => Valid ? $"{Abbrev}-memcached" : "—";
|
||||
public string PreviewServiceChart => Valid ? $"{Abbrev}-quickchart" : "—";
|
||||
public string PreviewServiceNewt => Valid ? $"{Abbrev}-newt" : "—";
|
||||
public string PreviewNetwork => Valid ? $"{Abbrev}-net" : "—";
|
||||
public string PreviewVolCustom => Valid ? $"{Abbrev}-cms-custom" : "—";
|
||||
public string PreviewVolBackup => Valid ? $"{Abbrev}-cms-backup" : "—";
|
||||
public string PreviewVolLibrary => Valid ? $"{Abbrev}-cms-library" : "—";
|
||||
public string PreviewVolUserscripts => Valid ? $"{Abbrev}-cms-userscripts": "—";
|
||||
public string PreviewVolCaCerts => Valid ? $"{Abbrev}-cms-ca-certs" : "—";
|
||||
public string PreviewSecret => Valid ? $"{Abbrev}-cms-db-password": "—";
|
||||
public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—";
|
||||
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms" : "—";
|
||||
public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—";
|
||||
|
||||
private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant();
|
||||
private bool Valid => Abbrev.Length == 3 && System.Text.RegularExpressions.Regex.IsMatch(Abbrev, "^[a-z]{3}$");
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public CreateInstanceViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadHostsAsync();
|
||||
_ = LoadCifsDefaultsAsync();
|
||||
}
|
||||
|
||||
partial void OnCustomerAbbrevChanged(string value) => RefreshPreview();
|
||||
|
||||
private void RefreshPreview()
|
||||
{
|
||||
OnPropertyChanged(nameof(PreviewStackName));
|
||||
OnPropertyChanged(nameof(PreviewServiceWeb));
|
||||
OnPropertyChanged(nameof(PreviewServiceCache));
|
||||
OnPropertyChanged(nameof(PreviewServiceChart));
|
||||
OnPropertyChanged(nameof(PreviewServiceNewt));
|
||||
OnPropertyChanged(nameof(PreviewNetwork));
|
||||
OnPropertyChanged(nameof(PreviewVolCustom));
|
||||
OnPropertyChanged(nameof(PreviewVolBackup));
|
||||
OnPropertyChanged(nameof(PreviewVolLibrary));
|
||||
OnPropertyChanged(nameof(PreviewVolUserscripts));
|
||||
OnPropertyChanged(nameof(PreviewVolCaCerts));
|
||||
OnPropertyChanged(nameof(PreviewSecret));
|
||||
OnPropertyChanged(nameof(PreviewMySqlDb));
|
||||
OnPropertyChanged(nameof(PreviewMySqlUser));
|
||||
OnPropertyChanged(nameof(PreviewCmsUrl));
|
||||
}
|
||||
|
||||
private async Task LoadHostsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
}
|
||||
|
||||
private async Task LoadCifsDefaultsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
|
||||
CifsShareBasePath = await settings.GetAsync(SettingsService.CifsShareBasePath) ?? string.Empty;
|
||||
CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty;
|
||||
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
|
||||
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeployAsync()
|
||||
{
|
||||
// ── Validation ───────────────────────────────────────────────────
|
||||
if (SelectedSshHost == null)
|
||||
{
|
||||
StatusMessage = "Select an SSH host first.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(CustomerName))
|
||||
{
|
||||
StatusMessage = "Customer Name is required.";
|
||||
return;
|
||||
}
|
||||
if (!Valid)
|
||||
{
|
||||
StatusMessage = "Abbreviation must be exactly 3 lowercase letters (a-z).";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Starting deployment...";
|
||||
DeployOutput = string.Empty;
|
||||
ProgressPercent = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Wire SSH host into docker services
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedSshHost);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(SelectedSshHost);
|
||||
var ssh = _services.GetRequiredService<SshConnectionService>();
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
|
||||
// ── Step 1: Clone template repo ────────────────────────────────
|
||||
SetProgress(10, "Cloning template repository...");
|
||||
// Handled inside InstanceService.CreateInstanceAsync
|
||||
|
||||
// ── Step 2: Generate MySQL password → Docker secret ────────────
|
||||
SetProgress(20, "Generating secrets...");
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
|
||||
// ── Step 3: Create MySQL database + user via SSH ───────────────
|
||||
SetProgress(35, "Creating MySQL database and user...");
|
||||
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
|
||||
Abbrev,
|
||||
mysqlPassword,
|
||||
cmd => ssh.RunCommandAsync(SelectedSshHost, cmd));
|
||||
|
||||
AppendOutput($"[MySQL] {mysqlMsg}");
|
||||
if (!mysqlOk)
|
||||
{
|
||||
StatusMessage = mysqlMsg;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 4: Create Docker Swarm secret ────────────────────────
|
||||
SetProgress(50, "Creating Docker Swarm secrets...");
|
||||
var secretName = $"{Abbrev}-cms-db-password";
|
||||
var (_, secretId) = await dockerSecrets.EnsureSecretAsync(secretName, mysqlPassword);
|
||||
AppendOutput($"[Secret] {secretName} → {secretId}");
|
||||
|
||||
// Password is now ONLY on the Swarm — clear from memory
|
||||
mysqlPassword = string.Empty;
|
||||
|
||||
// ── Step 5: Deploy stack ──────────────────────────────────────
|
||||
SetProgress(70, "Rendering compose & deploying stack...");
|
||||
|
||||
var dto = new CreateInstanceDto
|
||||
{
|
||||
CustomerName = CustomerName.Trim(),
|
||||
CustomerAbbrev = Abbrev,
|
||||
SshHostId = SelectedSshHost.Id,
|
||||
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
||||
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
||||
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
|
||||
CifsShareBasePath = string.IsNullOrWhiteSpace(CifsShareBasePath) ? null : CifsShareBasePath.Trim(),
|
||||
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
|
||||
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
|
||||
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
|
||||
};
|
||||
|
||||
var result = await instanceSvc.CreateInstanceAsync(dto);
|
||||
|
||||
AppendOutput(result.Output ?? string.Empty);
|
||||
SetProgress(100, result.Success ? "Deployment complete!" : "Deployment failed.");
|
||||
|
||||
StatusMessage = result.Success
|
||||
? $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms!"
|
||||
: $"Deploy failed: {result.ErrorMessage}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error: {ex.Message}";
|
||||
AppendOutput(ex.ToString());
|
||||
SetProgress(0, "Failed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetProgress(double pct, string step)
|
||||
{
|
||||
ProgressPercent = pct;
|
||||
ProgressStep = step;
|
||||
AppendOutput($"[{pct:0}%] {step}");
|
||||
}
|
||||
|
||||
private void AppendOutput(string text)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text;
|
||||
}
|
||||
|
||||
private static string GenerateRandomPassword(int length)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||
return RandomNumberGenerator.GetString(chars, length);
|
||||
}
|
||||
}
|
||||
|
||||
243
OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs
Normal file
243
OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for managing SSH host connections.
|
||||
/// Allows adding, editing, testing, and removing remote Docker Swarm hosts.
|
||||
/// </summary>
|
||||
public partial class HostsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _hosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedHost;
|
||||
[ObservableProperty] private bool _isEditing;
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
// Edit form fields
|
||||
[ObservableProperty] private string _editLabel = string.Empty;
|
||||
[ObservableProperty] private string _editHost = string.Empty;
|
||||
[ObservableProperty] private int _editPort = 22;
|
||||
[ObservableProperty] private string _editUsername = string.Empty;
|
||||
[ObservableProperty] private string _editPrivateKeyPath = string.Empty;
|
||||
[ObservableProperty] private string _editKeyPassphrase = string.Empty;
|
||||
[ObservableProperty] private string _editPassword = string.Empty;
|
||||
[ObservableProperty] private bool _editUseKeyAuth = true;
|
||||
private Guid? _editingHostId;
|
||||
|
||||
public HostsViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadHostsAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadHostsAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
|
||||
Hosts = new ObservableCollection<SshHost>(hosts);
|
||||
StatusMessage = $"Loaded {hosts.Count} host(s).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error loading hosts: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NewHost()
|
||||
{
|
||||
_editingHostId = null;
|
||||
EditLabel = string.Empty;
|
||||
EditHost = string.Empty;
|
||||
EditPort = 22;
|
||||
EditUsername = string.Empty;
|
||||
EditPrivateKeyPath = string.Empty;
|
||||
EditKeyPassphrase = string.Empty;
|
||||
EditPassword = string.Empty;
|
||||
EditUseKeyAuth = true;
|
||||
IsEditing = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void EditSelectedHost()
|
||||
{
|
||||
if (SelectedHost == null) return;
|
||||
|
||||
_editingHostId = SelectedHost.Id;
|
||||
EditLabel = SelectedHost.Label;
|
||||
EditHost = SelectedHost.Host;
|
||||
EditPort = SelectedHost.Port;
|
||||
EditUsername = SelectedHost.Username;
|
||||
EditPrivateKeyPath = SelectedHost.PrivateKeyPath ?? string.Empty;
|
||||
EditKeyPassphrase = string.Empty; // Don't show existing passphrase
|
||||
EditPassword = string.Empty; // Don't show existing password
|
||||
EditUseKeyAuth = SelectedHost.UseKeyAuth;
|
||||
IsEditing = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelEdit()
|
||||
{
|
||||
IsEditing = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveHostAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EditLabel) || string.IsNullOrWhiteSpace(EditHost) || string.IsNullOrWhiteSpace(EditUsername))
|
||||
{
|
||||
StatusMessage = "Label, Host, and Username are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
SshHost host;
|
||||
if (_editingHostId.HasValue)
|
||||
{
|
||||
host = await db.SshHosts.FindAsync(_editingHostId.Value)
|
||||
?? throw new KeyNotFoundException("Host not found.");
|
||||
|
||||
host.Label = EditLabel;
|
||||
host.Host = EditHost;
|
||||
host.Port = EditPort;
|
||||
host.Username = EditUsername;
|
||||
host.PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath;
|
||||
host.UseKeyAuth = EditUseKeyAuth;
|
||||
host.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (!string.IsNullOrEmpty(EditKeyPassphrase))
|
||||
host.KeyPassphrase = EditKeyPassphrase;
|
||||
if (!string.IsNullOrEmpty(EditPassword))
|
||||
host.Password = EditPassword;
|
||||
}
|
||||
else
|
||||
{
|
||||
host = new SshHost
|
||||
{
|
||||
Label = EditLabel,
|
||||
Host = EditHost,
|
||||
Port = EditPort,
|
||||
Username = EditUsername,
|
||||
PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath,
|
||||
KeyPassphrase = string.IsNullOrEmpty(EditKeyPassphrase) ? null : EditKeyPassphrase,
|
||||
Password = string.IsNullOrEmpty(EditPassword) ? null : EditPassword,
|
||||
UseKeyAuth = EditUseKeyAuth
|
||||
};
|
||||
db.SshHosts.Add(host);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
IsEditing = false;
|
||||
StatusMessage = $"Host '{host.Label}' saved.";
|
||||
await LoadHostsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error saving host: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteHostAsync()
|
||||
{
|
||||
if (SelectedHost == null) return;
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
var host = await db.SshHosts.FindAsync(SelectedHost.Id);
|
||||
if (host != null)
|
||||
{
|
||||
db.SshHosts.Remove(host);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Disconnect if connected
|
||||
var ssh = _services.GetRequiredService<SshConnectionService>();
|
||||
ssh.Disconnect(SelectedHost.Id);
|
||||
|
||||
StatusMessage = $"Host '{SelectedHost.Label}' deleted.";
|
||||
await LoadHostsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error deleting host: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TestConnectionAsync()
|
||||
{
|
||||
if (SelectedHost == null) return;
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Testing connection to {SelectedHost.Label}...";
|
||||
try
|
||||
{
|
||||
var ssh = _services.GetRequiredService<SshConnectionService>();
|
||||
var (success, message) = await ssh.TestConnectionAsync(SelectedHost);
|
||||
|
||||
// Update DB
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var host = await db.SshHosts.FindAsync(SelectedHost.Id);
|
||||
if (host != null)
|
||||
{
|
||||
host.LastTestedAt = DateTime.UtcNow;
|
||||
host.LastTestSuccess = success;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
StatusMessage = success
|
||||
? $"✓ {SelectedHost.Label}: {message}"
|
||||
: $"✗ {SelectedHost.Label}: {message}";
|
||||
|
||||
await LoadHostsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Connection test error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
186
OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs
Normal file
186
OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for listing, viewing, and managing CMS instances.
|
||||
/// </summary>
|
||||
public partial class InstancesViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<CmsInstance> _instances = new();
|
||||
[ObservableProperty] private CmsInstance? _selectedInstance;
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _filterText = string.Empty;
|
||||
[ObservableProperty] private ObservableCollection<StackInfo> _remoteStacks = new();
|
||||
[ObservableProperty] private ObservableCollection<ServiceInfo> _selectedServices = new();
|
||||
|
||||
// Available SSH hosts for the dropdown
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
|
||||
public InstancesViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = InitAsync();
|
||||
}
|
||||
|
||||
private async Task InitAsync()
|
||||
{
|
||||
await LoadHostsAsync();
|
||||
await LoadInstancesAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadHostsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadInstancesAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
var query = db.CmsInstances.Include(i => i.SshHost).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(FilterText))
|
||||
{
|
||||
query = query.Where(i =>
|
||||
i.CustomerName.Contains(FilterText) ||
|
||||
i.StackName.Contains(FilterText));
|
||||
}
|
||||
|
||||
var items = await query.OrderByDescending(i => i.CreatedAt).ToListAsync();
|
||||
Instances = new ObservableCollection<CmsInstance>(items);
|
||||
StatusMessage = $"Loaded {items.Count} instance(s).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshRemoteStacksAsync()
|
||||
{
|
||||
if (SelectedSshHost == null)
|
||||
{
|
||||
StatusMessage = "Select an SSH host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Listing stacks on {SelectedSshHost.Label}...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedSshHost);
|
||||
|
||||
var stacks = await dockerCli.ListStacksAsync();
|
||||
RemoteStacks = new ObservableCollection<StackInfo>(stacks);
|
||||
StatusMessage = $"Found {stacks.Count} stack(s) on {SelectedSshHost.Label}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error listing stacks: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task InspectInstanceAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
if (SelectedSshHost == null && SelectedInstance.SshHost == null)
|
||||
{
|
||||
StatusMessage = "No SSH host associated with this instance.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var host = SelectedInstance.SshHost ?? SelectedSshHost!;
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(host);
|
||||
|
||||
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
||||
SelectedServices = new ObservableCollection<ServiceInfo>(services);
|
||||
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error inspecting: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteInstanceAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Deleting {SelectedInstance.StackName}...";
|
||||
try
|
||||
{
|
||||
var host = SelectedInstance.SshHost ?? SelectedSshHost;
|
||||
if (host == null)
|
||||
{
|
||||
StatusMessage = "No SSH host available for deletion.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire up SSH-based docker services
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(host);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(host);
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
|
||||
var result = await instanceSvc.DeleteInstanceAsync(SelectedInstance.Id);
|
||||
StatusMessage = result.Success
|
||||
? $"Instance '{SelectedInstance.StackName}' deleted."
|
||||
: $"Delete failed: {result.ErrorMessage}";
|
||||
|
||||
await LoadInstancesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error deleting: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs
Normal file
56
OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for viewing operation logs.
|
||||
/// </summary>
|
||||
public partial class LogsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<OperationLog> _logs = new();
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private int _maxEntries = 100;
|
||||
|
||||
public LogsViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadLogsAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadLogsAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
var items = await db.OperationLogs
|
||||
.Include(l => l.Instance)
|
||||
.OrderByDescending(l => l.Timestamp)
|
||||
.Take(MaxEntries)
|
||||
.ToListAsync();
|
||||
|
||||
Logs = new ObservableCollection<OperationLog>(items);
|
||||
StatusMessage = $"Showing {items.Count} log entries.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error loading logs: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableObject? _currentView;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedNav = "Hosts";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = "Ready";
|
||||
|
||||
public ObservableCollection<string> NavItems { get; } = new()
|
||||
{
|
||||
"Hosts",
|
||||
"Instances",
|
||||
"Create Instance",
|
||||
"Secrets",
|
||||
"Settings",
|
||||
"Logs"
|
||||
};
|
||||
|
||||
public MainWindowViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
NavigateTo("Hosts");
|
||||
}
|
||||
|
||||
partial void OnSelectedNavChanged(string value)
|
||||
{
|
||||
NavigateTo(value);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NavigateTo(string page)
|
||||
{
|
||||
CurrentView = page switch
|
||||
{
|
||||
"Hosts" => (ObservableObject)_services.GetService(typeof(HostsViewModel))!,
|
||||
"Instances" => (ObservableObject)_services.GetService(typeof(InstancesViewModel))!,
|
||||
"Create Instance" => (ObservableObject)_services.GetService(typeof(CreateInstanceViewModel))!,
|
||||
"Secrets" => (ObservableObject)_services.GetService(typeof(SecretsViewModel))!,
|
||||
"Settings" => (ObservableObject)_services.GetService(typeof(SettingsViewModel))!,
|
||||
"Logs" => (ObservableObject)_services.GetService(typeof(LogsViewModel))!,
|
||||
_ => CurrentView
|
||||
};
|
||||
}
|
||||
|
||||
public void SetStatus(string message)
|
||||
{
|
||||
StatusMessage = message;
|
||||
}
|
||||
}
|
||||
68
OTSSignsOrchestrator.Desktop/ViewModels/SecretsViewModel.cs
Normal file
68
OTSSignsOrchestrator.Desktop/ViewModels/SecretsViewModel.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for viewing and managing Docker Swarm secrets on a remote host.
|
||||
/// </summary>
|
||||
public partial class SecretsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<SecretListItem> _secrets = new();
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
public SecretsViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadHostsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadHostsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadSecretsAsync()
|
||||
{
|
||||
if (SelectedSshHost == null)
|
||||
{
|
||||
StatusMessage = "Select an SSH host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var secretsSvc = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
secretsSvc.SetHost(SelectedSshHost);
|
||||
|
||||
var items = await secretsSvc.ListSecretsAsync();
|
||||
Secrets = new ObservableCollection<SecretListItem>(items);
|
||||
StatusMessage = $"Found {items.Count} secret(s) on {SelectedSshHost.Label}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
248
OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs
Normal file
248
OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, CIFS,
|
||||
/// and Instance Defaults configuration, persisted via SettingsService.
|
||||
/// </summary>
|
||||
public partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
// ── Git ──────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _gitRepoUrl = string.Empty;
|
||||
[ObservableProperty] private string _gitRepoPat = string.Empty;
|
||||
|
||||
// ── MySQL Admin ─────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _mySqlHost = string.Empty;
|
||||
[ObservableProperty] private string _mySqlPort = "3306";
|
||||
[ObservableProperty] private string _mySqlAdminUser = string.Empty;
|
||||
[ObservableProperty] private string _mySqlAdminPassword = string.Empty;
|
||||
|
||||
// ── SMTP ────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _smtpServer = string.Empty;
|
||||
[ObservableProperty] private string _smtpUsername = string.Empty;
|
||||
[ObservableProperty] private string _smtpPassword = string.Empty;
|
||||
[ObservableProperty] private bool _smtpUseTls = true;
|
||||
[ObservableProperty] private bool _smtpUseStartTls = true;
|
||||
[ObservableProperty] private string _smtpRewriteDomain = string.Empty;
|
||||
[ObservableProperty] private string _smtpHostname = string.Empty;
|
||||
[ObservableProperty] private string _smtpFromLineOverride = "NO";
|
||||
|
||||
// ── Pangolin ────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net";
|
||||
|
||||
// ── CIFS ────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
|
||||
[ObservableProperty] private string _cifsUsername = string.Empty;
|
||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
|
||||
|
||||
// ── Instance Defaults ───────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
|
||||
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
|
||||
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
|
||||
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
|
||||
[ObservableProperty] private string _defaultCmsServerNameTemplate = "{abbrev}.ots-signs.com";
|
||||
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
|
||||
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
|
||||
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms";
|
||||
[ObservableProperty] private string _defaultPhpPostMaxSize = "10G";
|
||||
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
|
||||
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
|
||||
|
||||
public SettingsViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
|
||||
// Git
|
||||
GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty);
|
||||
GitRepoPat = await svc.GetAsync(SettingsService.GitRepoPat, string.Empty);
|
||||
|
||||
// MySQL
|
||||
MySqlHost = await svc.GetAsync(SettingsService.MySqlHost, string.Empty);
|
||||
MySqlPort = await svc.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
MySqlAdminUser = await svc.GetAsync(SettingsService.MySqlAdminUser, string.Empty);
|
||||
MySqlAdminPassword = await svc.GetAsync(SettingsService.MySqlAdminPassword, string.Empty);
|
||||
|
||||
// SMTP
|
||||
SmtpServer = await svc.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||
SmtpUsername = await svc.GetAsync(SettingsService.SmtpUsername, string.Empty);
|
||||
SmtpPassword = await svc.GetAsync(SettingsService.SmtpPassword, string.Empty);
|
||||
SmtpUseTls = (await svc.GetAsync(SettingsService.SmtpUseTls, "YES")) == "YES";
|
||||
SmtpUseStartTls = (await svc.GetAsync(SettingsService.SmtpUseStartTls, "YES")) == "YES";
|
||||
SmtpRewriteDomain = await svc.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
|
||||
SmtpHostname = await svc.GetAsync(SettingsService.SmtpHostname, string.Empty);
|
||||
SmtpFromLineOverride = await svc.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
|
||||
|
||||
// Pangolin
|
||||
PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
// CIFS
|
||||
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty);
|
||||
CifsShareBasePath = await svc.GetAsync(SettingsService.CifsShareBasePath, string.Empty);
|
||||
CifsUsername = await svc.GetAsync(SettingsService.CifsUsername, string.Empty);
|
||||
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
|
||||
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
|
||||
// Instance Defaults
|
||||
DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
|
||||
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
|
||||
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
|
||||
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms");
|
||||
DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
|
||||
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
StatusMessage = "Settings loaded.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error loading settings: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
|
||||
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
|
||||
{
|
||||
// Git
|
||||
(SettingsService.GitRepoUrl, NullIfEmpty(GitRepoUrl), SettingsService.CatGit, false),
|
||||
(SettingsService.GitRepoPat, NullIfEmpty(GitRepoPat), SettingsService.CatGit, true),
|
||||
|
||||
// MySQL
|
||||
(SettingsService.MySqlHost, NullIfEmpty(MySqlHost), SettingsService.CatMySql, false),
|
||||
(SettingsService.MySqlPort, MySqlPort, SettingsService.CatMySql, false),
|
||||
(SettingsService.MySqlAdminUser, NullIfEmpty(MySqlAdminUser), SettingsService.CatMySql, false),
|
||||
(SettingsService.MySqlAdminPassword, NullIfEmpty(MySqlAdminPassword), SettingsService.CatMySql, true),
|
||||
|
||||
// SMTP
|
||||
(SettingsService.SmtpServer, NullIfEmpty(SmtpServer), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpUsername, NullIfEmpty(SmtpUsername), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpPassword, NullIfEmpty(SmtpPassword), SettingsService.CatSmtp, true),
|
||||
(SettingsService.SmtpUseTls, SmtpUseTls ? "YES" : "NO", SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpUseStartTls, SmtpUseStartTls ? "YES" : "NO", SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpRewriteDomain, NullIfEmpty(SmtpRewriteDomain), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpHostname, NullIfEmpty(SmtpHostname), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpFromLineOverride, SmtpFromLineOverride, SettingsService.CatSmtp, false),
|
||||
|
||||
// Pangolin
|
||||
(SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false),
|
||||
|
||||
// CIFS
|
||||
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsShareBasePath, NullIfEmpty(CifsShareBasePath), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
|
||||
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
|
||||
|
||||
// Instance Defaults
|
||||
(SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultNewtImage, DefaultNewtImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultMemcachedImage, DefaultMemcachedImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultQuickChartImage, DefaultQuickChartImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultCmsServerNameTemplate, DefaultCmsServerNameTemplate, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultThemeHostPath, DefaultThemeHostPath, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultMySqlDbTemplate, DefaultMySqlDbTemplate, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultMySqlUserTemplate, DefaultMySqlUserTemplate, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
|
||||
};
|
||||
|
||||
await svc.SaveManyAsync(settings);
|
||||
StatusMessage = "Settings saved successfully.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error saving settings: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TestMySqlConnectionAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(MySqlHost) || string.IsNullOrWhiteSpace(MySqlAdminUser))
|
||||
{
|
||||
StatusMessage = "MySQL Host and Admin User are required for connection test.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Testing MySQL connection via SSH...";
|
||||
try
|
||||
{
|
||||
// The test runs a mysql --version or a simple SELECT 1 query via SSH
|
||||
// We need an SshHost to route through — use the first available
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OTSSignsOrchestrator.Core.Data.XiboContext>();
|
||||
var host = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
|
||||
.FirstOrDefaultAsync(db.SshHosts);
|
||||
|
||||
if (host == null)
|
||||
{
|
||||
StatusMessage = "No SSH hosts configured. Add one in the Hosts page first.";
|
||||
return;
|
||||
}
|
||||
|
||||
var ssh = _services.GetRequiredService<Services.SshConnectionService>();
|
||||
var port = int.TryParse(MySqlPort, out var p) ? p : 3306;
|
||||
var cmd = $"mysql -h {MySqlHost} -P {port} -u {MySqlAdminUser} -p'{MySqlAdminPassword.Replace("'", "'\\''")}' -e 'SELECT 1' 2>&1";
|
||||
var (exitCode, stdout, stderr) = await ssh.RunCommandAsync(host, cmd);
|
||||
|
||||
StatusMessage = exitCode == 0
|
||||
? $"MySQL connection successful via {host.Label}."
|
||||
: $"MySQL connection failed: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"MySQL test error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
146
OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml
Normal file
146
OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml
Normal file
@@ -0,0 +1,146 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.CreateInstanceView"
|
||||
x:DataType="vm:CreateInstanceViewModel">
|
||||
|
||||
<ScrollViewer>
|
||||
<Grid ColumnDefinitions="1*,16,1*" Margin="16,12">
|
||||
|
||||
<!-- ══ LEFT COLUMN — inputs ══ -->
|
||||
<StackPanel Grid.Column="0" Spacing="8">
|
||||
<TextBlock Text="Create New Instance" FontSize="20" FontWeight="Bold" Margin="0,0,0,12" />
|
||||
|
||||
<!-- SSH Host -->
|
||||
<TextBlock Text="Deploy to SSH Host" FontSize="12" />
|
||||
<ComboBox ItemsSource="{Binding AvailableHosts}"
|
||||
SelectedItem="{Binding SelectedSshHost}"
|
||||
PlaceholderText="Select SSH Host..."
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<Separator Margin="0,8" />
|
||||
|
||||
<!-- Core fields -->
|
||||
<TextBlock Text="Customer Name" FontSize="12" />
|
||||
<TextBox Text="{Binding CustomerName}" Watermark="e.g. Acme Corp" />
|
||||
|
||||
<TextBlock Text="Abbreviation (3 letters)" FontSize="12" />
|
||||
<TextBox Text="{Binding CustomerAbbrev}"
|
||||
Watermark="e.g. acm"
|
||||
MaxLength="3" />
|
||||
|
||||
<Separator Margin="0,12" />
|
||||
|
||||
<!-- Pangolin / Newt (optional) -->
|
||||
<Expander Header="Pangolin / Newt credentials (optional)">
|
||||
<StackPanel Spacing="8" Margin="0,8,0,0">
|
||||
<TextBlock Text="Newt ID" FontSize="12" />
|
||||
<TextBox Text="{Binding NewtId}" Watermark="(from Pangolin dashboard)" />
|
||||
|
||||
<TextBlock Text="Newt Secret" FontSize="12" />
|
||||
<TextBox Text="{Binding NewtSecret}" PasswordChar="●" Watermark="(from Pangolin dashboard)" />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
|
||||
<!-- SMB / CIFS credentials (per-instance, defaults from global settings) -->
|
||||
<Expander Header="SMB / CIFS credentials">
|
||||
<StackPanel Spacing="8" Margin="0,8,0,0">
|
||||
<TextBlock Text="CIFS Server" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" />
|
||||
|
||||
<TextBlock Text="Share Base Path" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareBasePath}" Watermark="e.g. /share/cms" />
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsUsername}" Watermark="SMB username" />
|
||||
|
||||
<TextBlock Text="Password" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsPassword}" PasswordChar="●" Watermark="SMB password" />
|
||||
|
||||
<TextBlock Text="Extra Options" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsExtraOptions}" Watermark="file_mode=0777,dir_mode=0777" />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
|
||||
<Separator Margin="0,12" />
|
||||
|
||||
<!-- Deploy button + progress -->
|
||||
<Button Content="Deploy Instance"
|
||||
Command="{Binding DeployCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Padding="12,8" FontWeight="SemiBold" />
|
||||
|
||||
<!-- Progress bar -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0"
|
||||
IsVisible="{Binding IsBusy}">
|
||||
<ProgressBar Value="{Binding ProgressPercent}"
|
||||
Maximum="100" Height="6"
|
||||
CornerRadius="3" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding ProgressStep}"
|
||||
FontSize="11" Foreground="#a6adc8"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding StatusMessage}" FontSize="12" Foreground="#a6adc8"
|
||||
Margin="0,4,0,0" TextWrapping="Wrap" />
|
||||
|
||||
<!-- Deploy output -->
|
||||
<TextBox Text="{Binding DeployOutput}" IsReadOnly="True"
|
||||
AcceptsReturn="True" MaxHeight="260"
|
||||
FontFamily="Cascadia Mono, Consolas, monospace" FontSize="11"
|
||||
IsVisible="{Binding DeployOutput.Length}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ══ RIGHT COLUMN — live resource preview ══ -->
|
||||
<Border Grid.Column="2"
|
||||
Background="#1e1e2e"
|
||||
CornerRadius="8"
|
||||
Padding="16,14"
|
||||
VerticalAlignment="Top">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Resource Preview" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="#cdd6f4" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Text="Stack" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewStackName}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#89b4fa" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="Services" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewServiceWeb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
|
||||
<TextBlock Text="{Binding PreviewServiceCache}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
|
||||
<TextBlock Text="{Binding PreviewServiceChart}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
|
||||
<TextBlock Text="{Binding PreviewServiceNewt}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="Network" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewNetwork}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#94e2d5" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="CIFS Volumes" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewVolCustom}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
|
||||
<TextBlock Text="{Binding PreviewVolBackup}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
|
||||
<TextBlock Text="{Binding PreviewVolLibrary}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
|
||||
<TextBlock Text="{Binding PreviewVolUserscripts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
|
||||
<TextBlock Text="{Binding PreviewVolCaCerts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="Docker Secret" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewSecret}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="MySQL Database" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewMySqlDb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#cba6f7" />
|
||||
<TextBlock Text="{Binding PreviewMySqlUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#cba6f7" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="CMS URL" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewCmsUrl}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#89dceb" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class CreateInstanceView : UserControl
|
||||
{
|
||||
public CreateInstanceView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
83
OTSSignsOrchestrator.Desktop/Views/HostsView.axaml
Normal file
83
OTSSignsOrchestrator.Desktop/Views/HostsView.axaml
Normal file
@@ -0,0 +1,83 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.HostsView"
|
||||
x:DataType="vm:HostsViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Toolbar -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
|
||||
<Button Content="Add Host" Command="{Binding NewHostCommand}" />
|
||||
<Button Content="Edit" Command="{Binding EditSelectedHostCommand}" />
|
||||
<Button Content="Test Connection" Command="{Binding TestConnectionCommand}" />
|
||||
<Button Content="Delete" Command="{Binding DeleteHostCommand}" />
|
||||
<Button Content="Refresh" Command="{Binding LoadHostsCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Status -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
|
||||
FontSize="12" Foreground="#a6adc8" />
|
||||
|
||||
<!-- Edit panel (shown when editing) -->
|
||||
<Border DockPanel.Dock="Right" Width="350" IsVisible="{Binding IsEditing}"
|
||||
Background="#1e1e2e" CornerRadius="8" Padding="16" Margin="12,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="SSH Host" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Text="Label" FontSize="12" />
|
||||
<TextBox Text="{Binding EditLabel}" Watermark="e.g. Production Swarm" />
|
||||
|
||||
<TextBlock Text="Host" FontSize="12" />
|
||||
<TextBox Text="{Binding EditHost}" Watermark="hostname or IP" />
|
||||
|
||||
<TextBlock Text="Port" FontSize="12" />
|
||||
<NumericUpDown Value="{Binding EditPort}" Minimum="1" Maximum="65535" />
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" />
|
||||
<TextBox Text="{Binding EditUsername}" Watermark="ssh username" />
|
||||
|
||||
<CheckBox Content="Use Key Authentication" IsChecked="{Binding EditUseKeyAuth}" />
|
||||
|
||||
<TextBlock Text="Private Key Path" FontSize="12"
|
||||
IsVisible="{Binding EditUseKeyAuth}" />
|
||||
<TextBox Text="{Binding EditPrivateKeyPath}" Watermark="~/.ssh/id_rsa"
|
||||
IsVisible="{Binding EditUseKeyAuth}" />
|
||||
|
||||
<TextBlock Text="Key Passphrase (optional)" FontSize="12"
|
||||
IsVisible="{Binding EditUseKeyAuth}" />
|
||||
<TextBox Text="{Binding EditKeyPassphrase}" PasswordChar="●"
|
||||
IsVisible="{Binding EditUseKeyAuth}" />
|
||||
|
||||
<TextBlock Text="Password (if not using key)" FontSize="12"
|
||||
IsVisible="{Binding !EditUseKeyAuth}" />
|
||||
<TextBox Text="{Binding EditPassword}" PasswordChar="●"
|
||||
IsVisible="{Binding !EditUseKeyAuth}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,12,0,0">
|
||||
<Button Content="Save" Command="{Binding SaveHostCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding CancelEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- Host list -->
|
||||
<DataGrid ItemsSource="{Binding Hosts}"
|
||||
SelectedItem="{Binding SelectedHost}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Label" Binding="{Binding Label}" Width="150" />
|
||||
<DataGridTextColumn Header="Host" Binding="{Binding Host}" Width="200" />
|
||||
<DataGridTextColumn Header="Port" Binding="{Binding Port}" Width="60" />
|
||||
<DataGridTextColumn Header="User" Binding="{Binding Username}" Width="100" />
|
||||
<DataGridCheckBoxColumn Header="Key Auth" Binding="{Binding UseKeyAuth}" Width="70" />
|
||||
<DataGridTextColumn Header="Last Tested" Binding="{Binding LastTestedAt, StringFormat='{}{0:g}'}" Width="150" />
|
||||
<DataGridCheckBoxColumn Header="OK" Binding="{Binding LastTestSuccess}" Width="50" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/HostsView.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/HostsView.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class HostsView : UserControl
|
||||
{
|
||||
public HostsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
98
OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml
Normal file
98
OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml
Normal file
@@ -0,0 +1,98 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
|
||||
x:DataType="vm:InstancesViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Toolbar -->
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="8" Margin="0,0,0,12">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<ComboBox ItemsSource="{Binding AvailableHosts}"
|
||||
SelectedItem="{Binding SelectedSshHost}"
|
||||
PlaceholderText="Select SSH Host..."
|
||||
Width="250">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Content="List Remote Stacks" Command="{Binding RefreshRemoteStacksCommand}" />
|
||||
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
|
||||
<Button Content="Delete" Command="{Binding DeleteInstanceCommand}" />
|
||||
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding FilterText}" Watermark="Filter by name..." Width="250" />
|
||||
<Button Content="Search" Command="{Binding LoadInstancesCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Status -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
|
||||
FontSize="12" Foreground="#a6adc8" />
|
||||
|
||||
<!-- Services panel (shown when inspecting) -->
|
||||
<Border DockPanel.Dock="Right" Width="350"
|
||||
IsVisible="{Binding SelectedServices.Count}"
|
||||
Background="#1e1e2e" CornerRadius="8" Padding="12" Margin="12,0,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Stack Services" FontWeight="SemiBold" Margin="0,0,0,8" />
|
||||
<ItemsControl ItemsSource="{Binding SelectedServices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#313244" CornerRadius="4" Padding="8" Margin="0,2">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Image}" FontSize="11" Foreground="#a6adc8" />
|
||||
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
|
||||
FontSize="11" Foreground="#a6adc8" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Remote stacks panel -->
|
||||
<Border DockPanel.Dock="Bottom" MaxHeight="200"
|
||||
IsVisible="{Binding RemoteStacks.Count}"
|
||||
Background="#1e1e2e" CornerRadius="8" Padding="12" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Remote Stacks" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding RemoteStacks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock>
|
||||
<Run Text="{Binding Name}" FontWeight="SemiBold" />
|
||||
<Run Text="{Binding ServiceCount, StringFormat=' ({0} services)'}"
|
||||
Foreground="#a6adc8" />
|
||||
</TextBlock>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Instance list -->
|
||||
<DataGrid ItemsSource="{Binding Instances}"
|
||||
SelectedItem="{Binding SelectedInstance}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="120" />
|
||||
<DataGridTextColumn Header="Customer" Binding="{Binding CustomerName}" Width="120" />
|
||||
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
|
||||
<DataGridTextColumn Header="Server" Binding="{Binding CmsServerName}" Width="150" />
|
||||
<DataGridTextColumn Header="Port" Binding="{Binding HostHttpPort}" Width="60" />
|
||||
<DataGridTextColumn Header="Host" Binding="{Binding SshHost.Label}" Width="120" />
|
||||
<DataGridTextColumn Header="Created" Binding="{Binding CreatedAt, StringFormat='{}{0:g}'}" Width="140" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class InstancesView : UserControl
|
||||
{
|
||||
public InstancesView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
29
OTSSignsOrchestrator.Desktop/Views/LogsView.axaml
Normal file
29
OTSSignsOrchestrator.Desktop/Views/LogsView.axaml
Normal file
@@ -0,0 +1,29 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.LogsView"
|
||||
x:DataType="vm:LogsViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
|
||||
<Button Content="Refresh" Command="{Binding LoadLogsCommand}" />
|
||||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center"
|
||||
FontSize="12" Foreground="#a6adc8" Margin="8,0,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Logs}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Time" Binding="{Binding Timestamp, StringFormat='{}{0:g}'}" Width="150" />
|
||||
<DataGridTextColumn Header="Operation" Binding="{Binding Operation}" Width="100" />
|
||||
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
|
||||
<DataGridTextColumn Header="Instance" Binding="{Binding Instance.StackName}" Width="120" />
|
||||
<DataGridTextColumn Header="Message" Binding="{Binding Message}" Width="*" />
|
||||
<DataGridTextColumn Header="Duration" Binding="{Binding DurationMs, StringFormat='{}{0}ms'}" Width="80" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/LogsView.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/LogsView.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class LogsView : UserControl
|
||||
{
|
||||
public LogsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
62
OTSSignsOrchestrator.Desktop/Views/MainWindow.axaml
Normal file
62
OTSSignsOrchestrator.Desktop/Views/MainWindow.axaml
Normal file
@@ -0,0 +1,62 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
xmlns:views="using:OTSSignsOrchestrator.Desktop.Views"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="OTS Signs Orchestrator"
|
||||
Width="1200" Height="800"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<Window.DataTemplates>
|
||||
<DataTemplate DataType="vm:HostsViewModel">
|
||||
<views:HostsView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:InstancesViewModel">
|
||||
<views:InstancesView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:SecretsViewModel">
|
||||
<views:SecretsView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:LogsViewModel">
|
||||
<views:LogsView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:CreateInstanceViewModel">
|
||||
<views:CreateInstanceView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:SettingsViewModel">
|
||||
<views:SettingsView />
|
||||
</DataTemplate>
|
||||
</Window.DataTemplates>
|
||||
|
||||
<DockPanel>
|
||||
<!-- Status bar -->
|
||||
<Border DockPanel.Dock="Bottom" Background="#1e1e2e" Padding="8,4">
|
||||
<TextBlock Text="{Binding StatusMessage}" FontSize="12" Foreground="#a0a0a0" />
|
||||
</Border>
|
||||
|
||||
<!-- Left nav -->
|
||||
<Border DockPanel.Dock="Left" Width="180" Background="#181825" Padding="0,8">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="OTS Signs" FontSize="18" FontWeight="Bold" Foreground="#cdd6f4"
|
||||
Margin="16,8,16,16" />
|
||||
|
||||
<ListBox ItemsSource="{Binding NavItems}"
|
||||
SelectedItem="{Binding SelectedNav}"
|
||||
Background="Transparent"
|
||||
Margin="4,0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" Padding="12,8" FontSize="14" />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Main content -->
|
||||
<Border Padding="16">
|
||||
<ContentControl Content="{Binding CurrentView}" />
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/MainWindow.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/MainWindow.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
37
OTSSignsOrchestrator.Desktop/Views/SecretsView.axaml
Normal file
37
OTSSignsOrchestrator.Desktop/Views/SecretsView.axaml
Normal file
@@ -0,0 +1,37 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.SecretsView"
|
||||
x:DataType="vm:SecretsViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
|
||||
<ComboBox ItemsSource="{Binding AvailableHosts}"
|
||||
SelectedItem="{Binding SelectedSshHost}"
|
||||
PlaceholderText="Select SSH Host..."
|
||||
Width="250">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Content="Load Secrets" Command="{Binding LoadSecretsCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
|
||||
FontSize="12" Foreground="#a6adc8" />
|
||||
|
||||
<DataGrid ItemsSource="{Binding Secrets}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="200" />
|
||||
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="250" />
|
||||
<DataGridTextColumn Header="Created" Binding="{Binding CreatedAt, StringFormat='{}{0:g}'}" Width="180" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/SecretsView.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/SecretsView.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class SecretsView : UserControl
|
||||
{
|
||||
public SecretsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
205
OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml
Normal file
205
OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml
Normal file
@@ -0,0 +1,205 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.SettingsView"
|
||||
x:DataType="vm:SettingsViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Top toolbar -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
|
||||
<Button Content="Save All Settings"
|
||||
Command="{Binding SaveCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
FontWeight="SemiBold" Padding="16,8" />
|
||||
<Button Content="Reload" Command="{Binding LoadCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center"
|
||||
FontSize="12" Foreground="#a6adc8" Margin="12,0,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Scrollable settings content -->
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16" Margin="0,0,16,16" MaxWidth="800">
|
||||
|
||||
<!-- ═══ Git Repository ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Git Repository" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#89b4fa" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Template repository cloned for each new instance."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
|
||||
|
||||
<TextBlock Text="Repository URL" FontSize="12" />
|
||||
<TextBox Text="{Binding GitRepoUrl}"
|
||||
Watermark="https://github.com/org/template-repo.git" />
|
||||
|
||||
<TextBlock Text="Personal Access Token (PAT)" FontSize="12" />
|
||||
<TextBox Text="{Binding GitRepoPat}" PasswordChar="●"
|
||||
Watermark="ghp_xxxx..." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ MySQL Connection ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="MySQL Connection" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#a6e3a1" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Admin credentials used to create databases and users for new instances."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
|
||||
|
||||
<Grid ColumnDefinitions="3*,8,1*" RowDefinitions="Auto,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Host" FontSize="12" />
|
||||
<TextBox Text="{Binding MySqlHost}" Watermark="cms-sql.otshosting.app" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Port" FontSize="12" />
|
||||
<TextBox Text="{Binding MySqlPort}" Watermark="3306" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Admin Username" FontSize="12" />
|
||||
<TextBox Text="{Binding MySqlAdminUser}" Watermark="root" />
|
||||
|
||||
<TextBlock Text="Admin Password" FontSize="12" />
|
||||
<TextBox Text="{Binding MySqlAdminPassword}" PasswordChar="●" />
|
||||
|
||||
<Button Content="Test MySQL Connection"
|
||||
Command="{Binding TestMySqlConnectionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ SMTP Settings ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="SMTP Settings" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#f5c2e7" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Email configuration applied to all CMS instances."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
|
||||
|
||||
<TextBlock Text="SMTP Server (host:port)" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpServer}" Watermark="smtp.azurecomm.net:587" />
|
||||
|
||||
<TextBlock Text="SMTP Username" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpUsername}" Watermark="user@domain.com" />
|
||||
|
||||
<TextBlock Text="SMTP Password" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpPassword}" PasswordChar="●" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
||||
<CheckBox Content="Use TLS" IsChecked="{Binding SmtpUseTls}" />
|
||||
<CheckBox Content="Use STARTTLS" IsChecked="{Binding SmtpUseStartTls}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Rewrite Domain" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpRewriteDomain}" Watermark="ots-signs.com" />
|
||||
|
||||
<TextBlock Text="SMTP Hostname" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpHostname}" Watermark="demo.ots-signs.com" />
|
||||
|
||||
<TextBlock Text="From Line Override" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpFromLineOverride}" Watermark="NO" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Pangolin / Newt ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Pangolin (Newt Tunnel)" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#fab387" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Global Pangolin endpoint. Newt ID and Secret are configured per-instance."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
|
||||
|
||||
<TextBlock Text="Pangolin Endpoint URL" FontSize="12" />
|
||||
<TextBox Text="{Binding PangolinEndpoint}" Watermark="https://app.pangolin.net" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ CIFS Volumes ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="CIFS Volumes" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#cba6f7" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Network share settings for Docker volumes. Volumes will be mounted via CIFS."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
|
||||
|
||||
<TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsServer}" Watermark="nas.local" />
|
||||
|
||||
<TextBlock Text="Share Base Path" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareBasePath}" Watermark="/share/cms" />
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsUsername}" Watermark="smbuser" />
|
||||
|
||||
<TextBlock Text="Password" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsPassword}" PasswordChar="●" />
|
||||
|
||||
<TextBlock Text="Extra Mount Options" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsOptions}" Watermark="file_mode=0777,dir_mode=0777" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Instance Defaults ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Instance Defaults" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#89dceb" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Default Docker images, naming templates, and PHP settings for new instances. Use {abbrev} as a placeholder for the customer abbreviation."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Docker Images" FontSize="13" FontWeight="SemiBold" Margin="0,8,0,4" />
|
||||
|
||||
<TextBlock Text="CMS Image" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultCmsImage}"
|
||||
Watermark="ghcr.io/xibosignage/xibo-cms:release-4.2.3" />
|
||||
|
||||
<TextBlock Text="Newt Image" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultNewtImage}" Watermark="fosrl/newt" />
|
||||
|
||||
<TextBlock Text="Memcached Image" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultMemcachedImage}" Watermark="memcached:alpine" />
|
||||
|
||||
<TextBlock Text="QuickChart Image" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultQuickChartImage}" Watermark="ianw/quickchart" />
|
||||
|
||||
<TextBlock Text="Naming Templates" FontSize="13" FontWeight="SemiBold" Margin="0,12,0,4" />
|
||||
|
||||
<TextBlock Text="CMS Server Name Template" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultCmsServerNameTemplate}"
|
||||
Watermark="{}{abbrev}.ots-signs.com" />
|
||||
|
||||
<TextBlock Text="Theme Host Path Template" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultThemeHostPath}"
|
||||
Watermark="/cms/{abbrev}-cms-theme-custom" />
|
||||
|
||||
<TextBlock Text="MySQL Database Name Template" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultMySqlDbTemplate}" Watermark="{}{abbrev}_cms_db" />
|
||||
|
||||
<TextBlock Text="MySQL User Template" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultMySqlUserTemplate}" Watermark="{}{abbrev}_cms" />
|
||||
|
||||
<TextBlock Text="PHP Settings" FontSize="13" FontWeight="SemiBold" Margin="0,12,0,4" />
|
||||
|
||||
<Grid ColumnDefinitions="1*,8,1*,8,1*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Post Max Size" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultPhpPostMaxSize}" Watermark="10G" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Upload Max Filesize" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultPhpUploadMaxFilesize}" Watermark="10G" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" Spacing="4">
|
||||
<TextBlock Text="Max Execution Time" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultPhpMaxExecutionTime}" Watermark="600" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class SettingsView : UserControl
|
||||
{
|
||||
public SettingsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
13
OTSSignsOrchestrator.Desktop/app.manifest
Normal file
13
OTSSignsOrchestrator.Desktop/app.manifest
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="OTSSignsOrchestrator.Desktop"/>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
45
OTSSignsOrchestrator.Desktop/appsettings.json
Normal file
45
OTSSignsOrchestrator.Desktop/appsettings.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.EntityFrameworkCore": "Information"
|
||||
}
|
||||
},
|
||||
"FileLogging": {
|
||||
"Enabled": true,
|
||||
"Path": "logs",
|
||||
"RollingInterval": "Day",
|
||||
"RetentionDays": 7
|
||||
},
|
||||
"Git": {
|
||||
"CacheDir": ".template-cache"
|
||||
},
|
||||
"Docker": {
|
||||
"DefaultConstraints": [ "node.labels.xibo==true" ],
|
||||
"DeployTimeoutSeconds": 30,
|
||||
"ValidateBeforeDeploy": true
|
||||
},
|
||||
"Xibo": {
|
||||
"DefaultImages": {
|
||||
"Cms": "ghcr.io/xibosignage/xibo-cms:release-4.4.0",
|
||||
"Mysql": "mysql:8.4",
|
||||
"Memcached": "memcached:alpine",
|
||||
"QuickChart": "ianw/quickchart"
|
||||
},
|
||||
"TestConnectionTimeoutSeconds": 10
|
||||
},
|
||||
"Database": {
|
||||
"Provider": "Sqlite"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Default": "Data Source=otssigns-desktop.db"
|
||||
},
|
||||
"InstanceDefaults": {
|
||||
"CmsServerNameTemplate": "{abbrev}.ots-signs.com",
|
||||
"ThemeHostPath": "/cms/ots-theme",
|
||||
"LibraryShareSubPath": "{abbrev}-cms-library",
|
||||
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
|
||||
"MySqlUserTemplate": "{abbrev}_cms",
|
||||
"BaseHostHttpPort": 8080
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator", "OTSSignsOrchestrator\OTSSignsOrchestrator.csproj", "{67B192E6-375B-41D7-9537-E66DE1D057C5}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Core", "OTSSignsOrchestrator.Core\OTSSignsOrchestrator.Core.csproj", "{A1B2C3D4-1111-2222-3333-444455556666}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Desktop", "OTSSignsOrchestrator.Desktop\OTSSignsOrchestrator.Desktop.csproj", "{B2C3D4E5-5555-6666-7777-888899990000}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -14,9 +16,13 @@ Global
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
bin/
|
||||
obj/
|
||||
logs/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
template-cache/
|
||||
appsettings.*.local.json
|
||||
@@ -1,76 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
|
||||
namespace OTSSignsOrchestrator.API;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly string _adminToken;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(IOptions<Configuration.AuthenticationOptions> authOptions, ILogger<AuthController> logger)
|
||||
{
|
||||
_adminToken = authOptions.Value.LocalAdminToken;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify a local admin token and issue a cookie.
|
||||
/// </summary>
|
||||
[HttpPost("verify-token")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> VerifyToken([FromBody] TokenLoginDto dto)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_adminToken))
|
||||
return BadRequest(new { message = "Local admin token not configured." });
|
||||
|
||||
if (!string.Equals(dto.Token, _adminToken, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("Invalid admin token attempt from {IP}", HttpContext.Connection.RemoteIpAddress);
|
||||
return Unauthorized(new { message = "Invalid token." });
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, "LocalAdmin"),
|
||||
new(ClaimTypes.Role, AppConstants.AdminRole),
|
||||
new("auth_method", "admin_token")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await HttpContext.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
principal,
|
||||
new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = true,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddHours(8)
|
||||
});
|
||||
|
||||
_logger.LogInformation("Admin token login from {IP}", HttpContext.Connection.RemoteIpAddress);
|
||||
|
||||
return Ok(new { valid = true, message = "Authenticated as LocalAdmin." });
|
||||
}
|
||||
|
||||
[HttpGet("logout")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return Ok(new { message = "Logged out." });
|
||||
}
|
||||
}
|
||||
|
||||
public class TokenLoginDto
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.API;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class InstancesController : ControllerBase
|
||||
{
|
||||
private readonly InstanceService _instanceService;
|
||||
private readonly ILogger<InstancesController> _logger;
|
||||
|
||||
public InstancesController(InstanceService instanceService, ILogger<InstancesController> logger)
|
||||
{
|
||||
_instanceService = instanceService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 50, [FromQuery] string? filter = null)
|
||||
{
|
||||
var (items, totalCount) = await _instanceService.ListInstancesAsync(page, pageSize, filter);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
items = items.Select(i => new
|
||||
{
|
||||
i.Id,
|
||||
i.CustomerName,
|
||||
i.StackName,
|
||||
i.CmsServerName,
|
||||
i.HostHttpPort,
|
||||
Status = i.Status.ToString(),
|
||||
XiboApiStatus = i.XiboApiTestStatus.ToString(),
|
||||
i.CreatedAt,
|
||||
i.UpdatedAt
|
||||
}),
|
||||
totalCount,
|
||||
page,
|
||||
pageSize
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var instance = await _instanceService.GetInstanceAsync(id);
|
||||
if (instance == null) return NotFound();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
instance.Id,
|
||||
instance.CustomerName,
|
||||
instance.StackName,
|
||||
instance.CmsServerName,
|
||||
instance.HostHttpPort,
|
||||
instance.ThemeHostPath,
|
||||
instance.LibraryHostPath,
|
||||
instance.SmtpServer,
|
||||
instance.SmtpUsername,
|
||||
instance.Constraints,
|
||||
instance.TemplateRepoUrl,
|
||||
instance.TemplateLastFetch,
|
||||
Status = instance.Status.ToString(),
|
||||
instance.XiboUsername,
|
||||
// Never return XiboPassword
|
||||
XiboApiStatus = instance.XiboApiTestStatus.ToString(),
|
||||
instance.XiboApiTestedAt,
|
||||
instance.CreatedAt,
|
||||
instance.UpdatedAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateInstanceDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(ModelState);
|
||||
|
||||
var userId = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var result = await _instanceService.CreateInstanceAsync(dto, userId, ip);
|
||||
|
||||
return result.Success
|
||||
? Ok(result)
|
||||
: StatusCode(500, result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateInstanceDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(ModelState);
|
||||
|
||||
var userId = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var result = await _instanceService.UpdateInstanceAsync(id, dto, userId, ip);
|
||||
|
||||
return result.Success
|
||||
? Ok(result)
|
||||
: StatusCode(500, result);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> Delete(Guid id, [FromQuery] bool retainSecrets = false, [FromQuery] bool clearXiboCreds = true)
|
||||
{
|
||||
var userId = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var result = await _instanceService.DeleteInstanceAsync(id, retainSecrets, clearXiboCreds, userId, ip);
|
||||
|
||||
return result.Success
|
||||
? Ok(result)
|
||||
: StatusCode(500, result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/test-xibo-connection")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> TestXiboConnection(Guid id)
|
||||
{
|
||||
var result = await _instanceService.TestXiboConnectionAsync(id);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
|
||||
namespace OTSSignsOrchestrator.API;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/[controller]")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public class LogsController : ControllerBase
|
||||
{
|
||||
private readonly FileLoggingOptions _loggingOptions;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<LogsController> _logger;
|
||||
private readonly XiboContext _db;
|
||||
|
||||
public LogsController(
|
||||
IOptions<FileLoggingOptions> loggingOptions,
|
||||
IWebHostEnvironment env,
|
||||
ILogger<LogsController> logger,
|
||||
XiboContext db)
|
||||
{
|
||||
_loggingOptions = loggingOptions.Value;
|
||||
_env = env;
|
||||
_logger = logger;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tail recent log lines with optional filters.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public IActionResult GetLogs([FromQuery] int lines = 100, [FromQuery] string? filter = null, [FromQuery] string? level = null)
|
||||
{
|
||||
var logPath = _loggingOptions.Path;
|
||||
if (!Path.IsPathRooted(logPath))
|
||||
logPath = Path.Combine(_env.ContentRootPath, logPath);
|
||||
|
||||
if (!Directory.Exists(logPath))
|
||||
return Ok(new { lines = Array.Empty<string>(), path = logPath, message = "Log directory not found." });
|
||||
|
||||
// Find latest log file
|
||||
var logFiles = Directory.GetFiles(logPath, "app-*.log")
|
||||
.OrderByDescending(f => f)
|
||||
.ToList();
|
||||
|
||||
if (logFiles.Count == 0)
|
||||
return Ok(new { lines = Array.Empty<string>(), path = logPath, message = "No log files found." });
|
||||
|
||||
var latestFile = logFiles[0];
|
||||
var allLines = ReadLastLines(latestFile, lines * 2); // Read extra for filtering
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrWhiteSpace(level))
|
||||
{
|
||||
allLines = allLines.Where(l =>
|
||||
l.Contains($"[{level.ToUpperInvariant().PadRight(3)}]", StringComparison.OrdinalIgnoreCase) ||
|
||||
l.Contains($"[{level}]", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
allLines = allLines.Where(l =>
|
||||
l.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
var result = allLines.TakeLast(lines).ToList();
|
||||
var fileInfo = new FileInfo(latestFile);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
lines = result,
|
||||
path = latestFile,
|
||||
sizeBytes = fileInfo.Length,
|
||||
lastModified = fileInfo.LastWriteTimeUtc
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the latest log file.
|
||||
/// </summary>
|
||||
[HttpGet("download")]
|
||||
public IActionResult DownloadLog()
|
||||
{
|
||||
var logPath = _loggingOptions.Path;
|
||||
if (!Path.IsPathRooted(logPath))
|
||||
logPath = Path.Combine(_env.ContentRootPath, logPath);
|
||||
|
||||
var logFiles = Directory.GetFiles(logPath, "app-*.log")
|
||||
.OrderByDescending(f => f)
|
||||
.ToList();
|
||||
|
||||
if (logFiles.Count == 0)
|
||||
return NotFound("No log files found.");
|
||||
|
||||
var latestFile = logFiles[0];
|
||||
var stream = new FileStream(latestFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
return File(stream, "text/plain", Path.GetFileName(latestFile));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get recent operation logs from the database.
|
||||
/// </summary>
|
||||
[HttpGet("operations")]
|
||||
public async Task<IActionResult> GetOperations([FromQuery] int count = 50)
|
||||
{
|
||||
var ops = await _db.OperationLogs
|
||||
.Include(o => o.Instance)
|
||||
.OrderByDescending(o => o.Timestamp)
|
||||
.Take(Math.Min(count, 200))
|
||||
.Select(o => new
|
||||
{
|
||||
o.Id,
|
||||
o.Operation,
|
||||
o.Status,
|
||||
o.Message,
|
||||
o.Timestamp,
|
||||
o.DurationMs,
|
||||
o.UserId,
|
||||
StackName = o.Instance != null ? o.Instance.StackName : null
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(ops);
|
||||
}
|
||||
|
||||
private static List<string> ReadLastLines(string filePath, int lineCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
var lines = new List<string>();
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
return lines.TakeLast(lineCount).ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.API;
|
||||
|
||||
[ApiController]
|
||||
[Route("api")]
|
||||
public class OidcProvidersController : ControllerBase
|
||||
{
|
||||
private readonly OidcProviderService _providerService;
|
||||
private readonly ILogger<OidcProvidersController> _logger;
|
||||
|
||||
public OidcProvidersController(OidcProviderService providerService, ILogger<OidcProvidersController> logger)
|
||||
{
|
||||
_providerService = providerService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List active OIDC providers (no auth required — used by login page).
|
||||
/// </summary>
|
||||
[HttpGet("idp-providers")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ListActive()
|
||||
{
|
||||
var providers = await _providerService.GetActiveProvidersAsync();
|
||||
return Ok(new
|
||||
{
|
||||
items = providers.Select(p => new
|
||||
{
|
||||
p.Id,
|
||||
p.Name,
|
||||
p.IsEnabled,
|
||||
p.IsPrimary
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("admin/idp-providers")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> ListAll()
|
||||
{
|
||||
var providers = await _providerService.GetAllProvidersAsync();
|
||||
return Ok(new
|
||||
{
|
||||
items = providers.Select(p => new
|
||||
{
|
||||
p.Id,
|
||||
p.Name,
|
||||
p.Authority,
|
||||
p.ClientId,
|
||||
p.Audience,
|
||||
p.IsEnabled,
|
||||
p.IsPrimary,
|
||||
p.CreatedAt,
|
||||
p.UpdatedAt
|
||||
// Never return ClientSecret
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("admin/idp-providers")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateOidcProviderDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(ModelState);
|
||||
|
||||
var provider = await _providerService.CreateProviderAsync(dto);
|
||||
return Ok(new { provider.Id, provider.Name, provider.CreatedAt });
|
||||
}
|
||||
|
||||
[HttpPut("admin/idp-providers/{id:guid}")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOidcProviderDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(ModelState);
|
||||
|
||||
var provider = await _providerService.UpdateProviderAsync(id, dto);
|
||||
return Ok(new { provider.Id, provider.Name, provider.UpdatedAt });
|
||||
}
|
||||
|
||||
[HttpDelete("admin/idp-providers/{id:guid}")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
await _providerService.DeleteProviderAsync(id);
|
||||
return Ok(new { success = true, message = "Provider deleted." });
|
||||
}
|
||||
|
||||
[HttpPost("admin/idp-providers/{id:guid}/test")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> Test(Guid id)
|
||||
{
|
||||
var provider = await _providerService.GetProviderAsync(id);
|
||||
if (provider == null) return NotFound();
|
||||
|
||||
var (isValid, message) = await _providerService.TestConnectionAsync(provider);
|
||||
return Ok(new { isValid, message });
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.API;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class SecretsController : ControllerBase
|
||||
{
|
||||
private readonly XiboContext _db;
|
||||
private readonly DockerSecretsService _secretsService;
|
||||
private readonly ILogger<SecretsController> _logger;
|
||||
|
||||
public SecretsController(XiboContext db, DockerSecretsService secretsService, ILogger<SecretsController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_secretsService = secretsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List secret metadata (names and dates, NEVER values).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var dbSecrets = await _db.SecretMetadata
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(dbSecrets.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.IsGlobal,
|
||||
s.CustomerName,
|
||||
s.CreatedAt,
|
||||
s.LastRotatedAt
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotate a secret (delete + recreate with new value).
|
||||
/// </summary>
|
||||
[HttpPost("{name}/rotate")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> Rotate(string name, [FromBody] RotateSecretDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.NewValue))
|
||||
return BadRequest(new { message = "NewValue is required." });
|
||||
|
||||
_logger.LogInformation("Rotating secret: {SecretName}", name);
|
||||
|
||||
var (created, secretId) = await _secretsService.EnsureSecretAsync(name, dto.NewValue, rotate: true);
|
||||
|
||||
// Update DB metadata
|
||||
var meta = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == name);
|
||||
if (meta != null)
|
||||
{
|
||||
meta.LastRotatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = $"Secret '{name}' rotated." });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a secret.
|
||||
/// </summary>
|
||||
[HttpDelete("{name}")]
|
||||
[Authorize(Roles = AppConstants.AdminRole)]
|
||||
public async Task<IActionResult> Delete(string name)
|
||||
{
|
||||
_logger.LogInformation("Deleting secret: {SecretName}", name);
|
||||
|
||||
await _secretsService.DeleteSecretAsync(name);
|
||||
|
||||
var meta = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == name);
|
||||
if (meta != null)
|
||||
{
|
||||
_db.SecretMetadata.Remove(meta);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = $"Secret '{name}' deleted." });
|
||||
}
|
||||
}
|
||||
|
||||
public class RotateSecretDto
|
||||
{
|
||||
public string NewValue { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["OTSSignsOrchestrator.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,31 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<span class="me-3">@context.User.Identity?.Name</span>
|
||||
<a href="api/auth/logout">Logout</a>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a href="login">Login</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
@@ -1,98 +0,0 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">OTS Signs Orchestrator</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<AuthorizeView Roles="Admin">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="instances/create">
|
||||
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> New Instance
|
||||
</NavLink>
|
||||
</div>
|
||||
</AuthorizeView>
|
||||
|
||||
<AuthorizeView Roles="Admin">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="admin/oidc-providers">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> OIDC Providers
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="admin/secrets">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Secrets
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="admin/logs">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Logs
|
||||
</NavLink>
|
||||
</div>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
@page "/admin/logs"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
|
||||
@inject HttpClient Http
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Logs - Admin - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3>Application Logs</h3>
|
||||
<button class="btn btn-outline-primary" @onclick="DownloadLog">Download Current Log</button>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Level</label>
|
||||
<select @bind="level" class="form-select">
|
||||
<option value="">All</option>
|
||||
<option value="Information">Information</option>
|
||||
<option value="Warning">Warning</option>
|
||||
<option value="Error">Error</option>
|
||||
<option value="Debug">Debug</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Filter text</label>
|
||||
<input @bind="filter" class="form-control" placeholder="Search in log messages..." />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Lines</label>
|
||||
<select @bind="tailLines" class="form-select">
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-primary w-100" @onclick="LoadLogs" disabled="@loading">
|
||||
@(loading ? "Loading..." : "Refresh")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<pre class="bg-dark text-light p-3 m-0" style="max-height: 600px; overflow-y: auto; font-size: 0.8rem; white-space: pre-wrap; word-break: break-all;">@(logContent ?? "Click Refresh to load logs.")</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h5>Operation Logs</h5>
|
||||
<p class="text-muted">Recent deployment and management operations recorded in the database.</p>
|
||||
|
||||
@if (operationLogs == null)
|
||||
{
|
||||
<p>Loading operation logs...</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Operation</th>
|
||||
<th>Instance</th>
|
||||
<th>Status</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var log in operationLogs)
|
||||
{
|
||||
<tr class="@GetRowClass(log.Status)">
|
||||
<td><small>@log.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</small></td>
|
||||
<td>@log.Operation</td>
|
||||
<td>@(log.StackName ?? "—")</td>
|
||||
<td>
|
||||
<span class="badge @GetStatusBadge(log.Status)">@log.Status</span>
|
||||
</td>
|
||||
<td><small>@TruncateMessage(log.Message)</small></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string level = "";
|
||||
private string filter = "";
|
||||
private int tailLines = 100;
|
||||
private string? logContent;
|
||||
private string? errorMessage;
|
||||
private bool loading;
|
||||
private List<OperationLogEntry>? operationLogs;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadOperationLogs();
|
||||
}
|
||||
|
||||
private async Task LoadLogs()
|
||||
{
|
||||
loading = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var query = $"/api/admin/logs?lines={tailLines}";
|
||||
if (!string.IsNullOrWhiteSpace(level)) query += $"&level={level}";
|
||||
if (!string.IsNullOrWhiteSpace(filter)) query += $"&filter={Uri.EscapeDataString(filter)}";
|
||||
|
||||
var response = await Http.GetAsync(query);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadFromJsonAsync<LogResponse>();
|
||||
logContent = json?.Lines != null ? string.Join("\n", json.Lines) : "No log entries.";
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = $"Failed to load logs: {response.StatusCode}";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadOperationLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await Http.GetAsync("/api/admin/logs/operations?count=50");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
operationLogs = await response.Content.ReadFromJsonAsync<List<OperationLogEntry>>() ?? new();
|
||||
}
|
||||
else
|
||||
{
|
||||
operationLogs = new();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
operationLogs = new();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadLog()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
Navigation.NavigateTo("/api/admin/logs/download", forceLoad: true);
|
||||
}
|
||||
|
||||
private static string GetStatusBadge(string status) => status switch
|
||||
{
|
||||
"Success" => "bg-success",
|
||||
"Failure" => "bg-danger",
|
||||
"Pending" => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private static string GetRowClass(string status) => status switch
|
||||
{
|
||||
"Failure" => "table-danger",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private static string TruncateMessage(string? msg) =>
|
||||
msg?.Length > 120 ? msg[..120] + "..." : msg ?? "";
|
||||
|
||||
private class LogResponse
|
||||
{
|
||||
public List<string>? Lines { get; set; }
|
||||
public string? Path { get; set; }
|
||||
}
|
||||
|
||||
private class OperationLogEntry
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Operation { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string? Message { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
public long? DurationMs { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? StackName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
@page "/admin/oidc-providers"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
|
||||
@inject OidcProviderService ProviderSvc
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>OIDC Providers - Admin - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3>OIDC Providers</h3>
|
||||
<button class="btn btn-primary" @onclick="ShowAddForm">+ Add Provider</button>
|
||||
</div>
|
||||
|
||||
@if (errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible">
|
||||
@errorMessage
|
||||
<button type="button" class="btn-close" @onclick="() => errorMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
@if (successMessage != null)
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible">
|
||||
@successMessage
|
||||
<button type="button" class="btn-close" @onclick="() => successMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (providers == null)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (providers.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No OIDC providers configured. Add one to enable external authentication.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Authority</th>
|
||||
<th>Client ID</th>
|
||||
<th>Status</th>
|
||||
<th>Primary</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var p in providers)
|
||||
{
|
||||
<tr>
|
||||
<td>@p.Name</td>
|
||||
<td><small>@p.Authority</small></td>
|
||||
<td><code>@p.ClientId</code></td>
|
||||
<td>
|
||||
<span class="badge @(p.IsEnabled ? "bg-success" : "bg-secondary")">
|
||||
@(p.IsEnabled ? "Enabled" : "Disabled")
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (p.IsPrimary)
|
||||
{
|
||||
<span class="badge bg-primary">Primary</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => StartEdit(p)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-info me-1" @onclick="() => TestProvider(p.Id)" disabled="@testingId.HasValue">Test</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteProvider(p.Id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@* Add/Edit Modal *@
|
||||
@if (showForm)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(editingId.HasValue ? "Edit Provider" : "Add Provider")</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseForm"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input @bind="formName" class="form-control" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Authority (issuer URL)</label>
|
||||
<input @bind="formAuthority" class="form-control" placeholder="https://login.example.com" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client ID</label>
|
||||
<input @bind="formClientId" class="form-control" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client Secret @(editingId.HasValue ? "(leave blank to keep)" : "")</label>
|
||||
<input @bind="formClientSecret" class="form-control" type="password" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Audience (optional)</label>
|
||||
<input @bind="formAudience" class="form-control" />
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input @bind="formIsEnabled" class="form-check-input" type="checkbox" id="chkEnabled" />
|
||||
<label class="form-check-label" for="chkEnabled">Enabled</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input @bind="formIsPrimary" class="form-check-input" type="checkbox" id="chkPrimary" />
|
||||
<label class="form-check-label" for="chkPrimary">Primary (used on login page)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="CloseForm">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="SaveProvider" disabled="@formSaving">
|
||||
@(formSaving ? "Saving..." : "Save")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<OidcProvider>? providers;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private Guid? testingId;
|
||||
|
||||
// Form state
|
||||
private bool showForm;
|
||||
private Guid? editingId;
|
||||
private string formName = "";
|
||||
private string formAuthority = "";
|
||||
private string formClientId = "";
|
||||
private string formClientSecret = "";
|
||||
private string? formAudience;
|
||||
private bool formIsEnabled = true;
|
||||
private bool formIsPrimary;
|
||||
private bool formSaving;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadProviders();
|
||||
}
|
||||
|
||||
private async Task LoadProviders()
|
||||
{
|
||||
providers = await ProviderSvc.GetAllProvidersAsync();
|
||||
}
|
||||
|
||||
private void ShowAddForm()
|
||||
{
|
||||
editingId = null;
|
||||
formName = "";
|
||||
formAuthority = "";
|
||||
formClientId = "";
|
||||
formClientSecret = "";
|
||||
formAudience = null;
|
||||
formIsEnabled = true;
|
||||
formIsPrimary = false;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
private void StartEdit(OidcProvider p)
|
||||
{
|
||||
editingId = p.Id;
|
||||
formName = p.Name;
|
||||
formAuthority = p.Authority;
|
||||
formClientId = p.ClientId;
|
||||
formClientSecret = "";
|
||||
formAudience = p.Audience;
|
||||
formIsEnabled = p.IsEnabled;
|
||||
formIsPrimary = p.IsPrimary;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
private void CloseForm()
|
||||
{
|
||||
showForm = false;
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
private async Task SaveProvider()
|
||||
{
|
||||
formSaving = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (editingId.HasValue)
|
||||
{
|
||||
var dto = new UpdateOidcProviderDto
|
||||
{
|
||||
Name = formName,
|
||||
Authority = formAuthority,
|
||||
ClientId = formClientId,
|
||||
ClientSecret = string.IsNullOrWhiteSpace(formClientSecret) ? null : formClientSecret,
|
||||
Audience = formAudience,
|
||||
IsEnabled = formIsEnabled,
|
||||
IsPrimary = formIsPrimary
|
||||
};
|
||||
await ProviderSvc.UpdateProviderAsync(editingId.Value, dto);
|
||||
successMessage = "Provider updated.";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(formClientSecret))
|
||||
{
|
||||
errorMessage = "Client secret is required for new providers.";
|
||||
return;
|
||||
}
|
||||
var dto = new CreateOidcProviderDto
|
||||
{
|
||||
Name = formName,
|
||||
Authority = formAuthority,
|
||||
ClientId = formClientId,
|
||||
ClientSecret = formClientSecret,
|
||||
Audience = formAudience,
|
||||
IsEnabled = formIsEnabled,
|
||||
IsPrimary = formIsPrimary
|
||||
};
|
||||
await ProviderSvc.CreateProviderAsync(dto);
|
||||
successMessage = "Provider created.";
|
||||
}
|
||||
|
||||
await LoadProviders();
|
||||
CloseForm();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
formSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TestProvider(Guid id)
|
||||
{
|
||||
testingId = id;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var provider = await ProviderSvc.GetProviderAsync(id);
|
||||
if (provider == null)
|
||||
{
|
||||
errorMessage = "Provider not found.";
|
||||
return;
|
||||
}
|
||||
|
||||
var (isValid, message) = await ProviderSvc.TestConnectionAsync(provider);
|
||||
if (isValid)
|
||||
successMessage = $"Provider connectivity OK: {message}";
|
||||
else
|
||||
errorMessage = $"Provider connectivity test failed: {message}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Test failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
testingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteProvider(Guid id)
|
||||
{
|
||||
errorMessage = null;
|
||||
try
|
||||
{
|
||||
await ProviderSvc.DeleteProviderAsync(id);
|
||||
successMessage = "Provider deleted.";
|
||||
await LoadProviders();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
@page "/admin/secrets"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
|
||||
@inject DockerSecretsService SecretsSvc
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Secrets - Admin - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
<h3>Docker Secrets</h3>
|
||||
<p class="text-muted">Manage Docker Swarm secrets. Values are never displayed. You can rotate (delete + recreate) or remove secrets.</p>
|
||||
|
||||
@if (errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible">
|
||||
@errorMessage
|
||||
<button type="button" class="btn-close" @onclick="() => errorMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
@if (successMessage != null)
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible">
|
||||
@successMessage
|
||||
<button type="button" class="btn-close" @onclick="() => successMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (secrets == null)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (secrets.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No secrets found in Docker Swarm.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in secrets)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@s.Name</code></td>
|
||||
<td>@s.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-warning me-1"
|
||||
@onclick="() => ShowRotateModal(s)"
|
||||
disabled="@busy">
|
||||
Rotate
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@onclick="() => ShowDeleteModal(s)"
|
||||
disabled="@busy">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@* Rotate Modal *@
|
||||
@if (rotateTarget != null)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Rotate Secret: @rotateTarget.Name</h5>
|
||||
<button type="button" class="btn-close" @onclick="() => rotateTarget = null"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Enter the new value for this secret. The existing secret will be deleted and recreated.</p>
|
||||
<div class="alert alert-warning">
|
||||
<strong>Warning:</strong> Services referencing this secret must be redeployed after rotation.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Secret Value</label>
|
||||
<input @bind="newSecretValue" class="form-control" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="() => rotateTarget = null">Cancel</button>
|
||||
<button class="btn btn-warning" @onclick="RotateSecret" disabled="@busy">
|
||||
@(busy ? "Rotating..." : "Rotate")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Delete Modal *@
|
||||
@if (deleteTarget != null)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Secret: @deleteTarget.Name</h5>
|
||||
<button type="button" class="btn-close" @onclick="() => deleteTarget = null"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-danger">
|
||||
This will permanently remove the secret from Docker Swarm. Any stacks referencing it will fail.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="() => deleteTarget = null">Cancel</button>
|
||||
<button class="btn btn-danger" @onclick="DeleteSecret" disabled="@busy">
|
||||
@(busy ? "Deleting..." : "Delete")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<SecretListItem>? secrets;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private bool busy;
|
||||
|
||||
private SecretListItem? rotateTarget;
|
||||
private SecretListItem? deleteTarget;
|
||||
private string newSecretValue = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
private async Task LoadSecrets()
|
||||
{
|
||||
try
|
||||
{
|
||||
secrets = await SecretsSvc.ListSecretsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
secrets = new();
|
||||
errorMessage = $"Could not connect to Docker: {ex.InnerException?.Message ?? ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowRotateModal(SecretListItem s)
|
||||
{
|
||||
rotateTarget = s;
|
||||
newSecretValue = "";
|
||||
}
|
||||
|
||||
private void ShowDeleteModal(SecretListItem s)
|
||||
{
|
||||
deleteTarget = s;
|
||||
}
|
||||
|
||||
private async Task RotateSecret()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newSecretValue))
|
||||
{
|
||||
errorMessage = "New secret value is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await SecretsSvc.EnsureSecretAsync(rotateTarget!.Name, newSecretValue, rotate: true);
|
||||
successMessage = $"Secret '{rotateTarget.Name}' rotated successfully.";
|
||||
rotateTarget = null;
|
||||
await LoadSecrets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Rotation failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSecret()
|
||||
{
|
||||
busy = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await SecretsSvc.DeleteSecretAsync(deleteTarget!.Name);
|
||||
successMessage = $"Secret '{deleteTarget.Name}' deleted.";
|
||||
deleteTarget = null;
|
||||
await LoadSecrets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Delete failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +1,376 @@
|
||||
@page "/instances/create"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
|
||||
@inject InstanceService InstanceSvc
|
||||
@inject XiboApiService XiboApi
|
||||
@inject NavigationManager Navigation
|
||||
@inject IOptions<InstanceDefaultsOptions> Defaults
|
||||
@using Microsoft.Extensions.Options
|
||||
@using OTSSignsOrchestrator.Configuration
|
||||
|
||||
<PageTitle>Create Instance - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
<h4 class="mb-4">New CMS Instance</h4>
|
||||
|
||||
<div class="row g-4">
|
||||
@* ── Left column: form ── *@
|
||||
<div class="col-lg-5">
|
||||
<EditForm Model="model" OnValidSubmit="HandleSubmit" FormName="createInstance">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="alert alert-danger py-2 small" />
|
||||
|
||||
<fieldset disabled="@deploying">
|
||||
|
||||
@* ── Customer ── *@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Customer Name</label>
|
||||
<InputText @bind-Value="model.CustomerName" class="form-control"
|
||||
placeholder="Acme Corporation"
|
||||
@oninput="OnNameInput" />
|
||||
<div class="form-text">Display name — stored as a comment in the stack file.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Abbreviation <span class="text-muted fw-normal">(3 letters)</span></label>
|
||||
<InputText @bind-Value="model.CustomerAbbrev" class="form-control font-monospace text-uppercase"
|
||||
placeholder="ACM" maxlength="3"
|
||||
style="text-transform:uppercase; letter-spacing:.15em; width:6rem;"
|
||||
@oninput="OnAbbrevInput" />
|
||||
<div class="form-text">3 letters used as a prefix for every stack resource.</div>
|
||||
</div>
|
||||
|
||||
@* ── Optional overrides ── *@
|
||||
<details class="mb-3">
|
||||
<summary class="text-muted small" style="cursor:pointer;">Advanced overrides</summary>
|
||||
<div class="mt-2 ps-2 border-start">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Template Repo URL <span class="text-muted">(leave blank to use default)</span></label>
|
||||
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control form-control-sm"
|
||||
placeholder="@Defaults.Value.TemplateRepoUrl" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Placement Constraints <span class="text-muted">(comma-separated)</span></label>
|
||||
<InputText @bind-Value="constraintsText" class="form-control form-control-sm"
|
||||
placeholder="node.labels.xibo==true" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col mb-2">
|
||||
<label class="form-label small">Xibo Client ID</label>
|
||||
<InputText @bind-Value="model.XiboUsername" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col mb-2">
|
||||
<label class="form-label small">Xibo Client Secret</label>
|
||||
<InputText @bind-Value="model.XiboPassword" class="form-control form-control-sm" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-success px-4" disabled="@deploying">
|
||||
@if (deploying)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
|
||||
<span>Deploying…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Deploy</span>
|
||||
}
|
||||
</button>
|
||||
<a href="/" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
</EditForm>
|
||||
|
||||
@if (!string.IsNullOrEmpty(resultMessage))
|
||||
{
|
||||
<div class="alert @(resultSuccess ? "alert-success" : "alert-danger") mt-3">
|
||||
@resultMessage
|
||||
@if (resultSuccess && createdInstanceId.HasValue)
|
||||
{
|
||||
<a href="instances/@createdInstanceId" class="alert-link ms-2">View Instance →</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Right column: live preview ── *@
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100 border-0 bg-body-tertiary">
|
||||
<div class="card-header border-0 bg-body-tertiary d-flex align-items-center gap-2">
|
||||
<span class="fw-semibold">Resource Preview</span>
|
||||
@if (AbbrevIsValid)
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">@Abbrev</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary border">enter abbreviation</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body pt-2">
|
||||
|
||||
@* Stack *@
|
||||
<PreviewRow Label="Stack name" Value="@Abbrev" Icon="layers" />
|
||||
<PreviewRow Label="Overlay network" Value="@($"{Abbrev}-net")" Icon="share" />
|
||||
|
||||
<hr class="my-2" />
|
||||
|
||||
@* Services *@
|
||||
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Services</div>
|
||||
<PreviewRow Label="Web (Xibo CMS)" Value="@($"{Abbrev}-web")" />
|
||||
<PreviewRow Label="Memcached" Value="@($"{Abbrev}-memcached")" />
|
||||
<PreviewRow Label="QuickChart" Value="@($"{Abbrev}-quickchart")" />
|
||||
<PreviewRow Label="Newt (tunnel)" Value="@($"{Abbrev}-newt")" Muted="true" Note="(if template provides NEWT_ID)" />
|
||||
|
||||
<hr class="my-2" />
|
||||
|
||||
@* Volumes *@
|
||||
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">CIFS Volumes</div>
|
||||
@foreach (var vol in new[] { "cms-custom", "cms-backup", "cms-library", "cms-userscripts", "cms-ca-certs" })
|
||||
{
|
||||
<PreviewRow Label="@vol" Value="@($"{Abbrev}-{vol}")" />
|
||||
}
|
||||
<PreviewRow Label="db-data" Value="@($"{Abbrev}-db-data")" Note="(local driver)" Muted="true" />
|
||||
|
||||
<hr class="my-2" />
|
||||
|
||||
@* Docker secret *@
|
||||
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Docker Secret</div>
|
||||
<PreviewRow Label="MySQL password" Value="@($"{Abbrev}_mysql_password")" Icon="key" />
|
||||
|
||||
<hr class="my-2" />
|
||||
|
||||
@* External config *@
|
||||
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">External Resources</div>
|
||||
<PreviewRow Label="CMS URL" Value="@CmsServer" Icon="globe" />
|
||||
<PreviewRow Label="MySQL database" Value="@MySqlDb" />
|
||||
<PreviewRow Label="MySQL user" Value="@MySqlUser" />
|
||||
<PreviewRow Label="Theme host path" Value="@ThemePath" />
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl))
|
||||
{
|
||||
<hr class="my-2" />
|
||||
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Template</div>
|
||||
<PreviewRow Label="Repo" Value="@Defaults.Value.TemplateRepoUrl" Icon="git-branch" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning py-1 px-2 small mt-2 mb-0">
|
||||
⚠ No template repo configured. <a href="/settings">Set it in Settings</a> before deploying.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Inline sub-component for a labelled preview row *@
|
||||
@code {
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
private CreateInstanceDto model = new();
|
||||
private string? constraintsText;
|
||||
private bool deploying;
|
||||
private string? resultMessage;
|
||||
private bool resultSuccess;
|
||||
private Guid? createdInstanceId;
|
||||
|
||||
// ── Derived / preview ────────────────────────────────────────────────────
|
||||
|
||||
private string Abbrev =>
|
||||
string.IsNullOrWhiteSpace(model.CustomerAbbrev)
|
||||
? "???"
|
||||
: model.CustomerAbbrev.ToLowerInvariant()[..Math.Min(3, model.CustomerAbbrev.Length)];
|
||||
|
||||
private bool AbbrevIsValid =>
|
||||
!string.IsNullOrWhiteSpace(model.CustomerAbbrev) && model.CustomerAbbrev.Length == 3;
|
||||
|
||||
private string Apply(string template) => template.Replace("{abbrev}", Abbrev);
|
||||
|
||||
private string CmsServer => Apply(Defaults.Value.CmsServerNameTemplate);
|
||||
private string MySqlDb => Apply(Defaults.Value.MySqlDatabaseTemplate);
|
||||
private string MySqlUser => Apply(Defaults.Value.MySqlUserTemplate);
|
||||
private string ThemePath => string.IsNullOrWhiteSpace(model.ThemeHostPath)
|
||||
? Defaults.Value.ThemeHostPath
|
||||
: model.ThemeHostPath;
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnNameInput(ChangeEventArgs e)
|
||||
{
|
||||
// Auto-suggest abbreviation from first word if user hasn't typed one yet
|
||||
if (string.IsNullOrWhiteSpace(model.CustomerAbbrev))
|
||||
{
|
||||
var word = (e.Value?.ToString() ?? "")
|
||||
.Split([' ', '-', '_'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault(w => w.Length > 0);
|
||||
if (!string.IsNullOrEmpty(word))
|
||||
model.CustomerAbbrev = word[..Math.Min(3, word.Length)].ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAbbrevInput(ChangeEventArgs e)
|
||||
{
|
||||
// Enforce uppercase in the bound value immediately
|
||||
model.CustomerAbbrev = (e.Value?.ToString() ?? "").ToUpperInvariant();
|
||||
}
|
||||
|
||||
// ── Submit ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
deploying = true;
|
||||
resultMessage = null;
|
||||
model.CustomerAbbrev = model.CustomerAbbrev?.ToUpperInvariant() ?? string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(constraintsText))
|
||||
{
|
||||
model.Constraints = constraintsText
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(c => c.Trim())
|
||||
.Where(c => !string.IsNullOrEmpty(c))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var result = await InstanceSvc.CreateInstanceAsync(model);
|
||||
|
||||
resultSuccess = result.Success;
|
||||
resultMessage = result.Success
|
||||
? $"Instance '{result.StackName}' deployed in {result.DurationMs}ms ({result.ServiceCount} services)."
|
||||
: $"Deployment failed: {result.ErrorMessage}";
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
var page = await InstanceSvc.ListInstancesAsync(1, 1, Abbrev);
|
||||
createdInstanceId = page.Items.FirstOrDefault()?.Id;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
resultSuccess = false;
|
||||
resultMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
deploying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inline sub-component: preview row ────────────────────────────────────
|
||||
|
||||
private RenderFragment PreviewRow(string Label, string Value, string? Icon = null, string? Note = null, bool Muted = false) =>
|
||||
@<div class="d-flex align-items-baseline gap-2 mb-1 small">
|
||||
<span class="text-muted" style="min-width:9rem;">@Label</span>
|
||||
<code class="@(Muted ? "text-secondary" : "text-body") flex-fill" style="font-size:.875em;">@Value</code>
|
||||
@if (!string.IsNullOrEmpty(Note))
|
||||
{
|
||||
<span class="text-muted fst-italic" style="font-size:.8em;">@Note</span>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
<PageTitle>Create Instance - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
<h3>Create CMS Instance</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="col-lg-6">
|
||||
<EditForm Model="model" OnValidSubmit="HandleSubmit" FormName="createInstance">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" />
|
||||
|
||||
<fieldset disabled="@deploying">
|
||||
@* Customer Details *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Customer Details</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Customer Name</label>
|
||||
<InputText @bind-Value="model.CustomerName" class="form-control" placeholder="acme-corp" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Stack Name</label>
|
||||
<InputText @bind-Value="model.StackName" class="form-control" placeholder="acme-xibo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* CMS Configuration *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">CMS Configuration</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label class="form-label">CMS Server Name</label>
|
||||
<InputText @bind-Value="model.CmsServerName" class="form-control" placeholder="cms.example.com" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Host HTTP Port</label>
|
||||
<InputNumber @bind-Value="model.HostHttpPort" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Storage Paths *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Storage Paths (Host Bind Mounts)</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Theme Host Path</label>
|
||||
<InputText @bind-Value="model.ThemeHostPath" class="form-control" placeholder="/data/xibo-theme" />
|
||||
<label class="form-label fw-semibold">Customer Name</label>
|
||||
<InputText @bind-Value="model.CustomerName" class="form-control"
|
||||
placeholder="Acme Corporation"
|
||||
@oninput="OnCustomerNameInput" />
|
||||
<div class="form-text">Full display name — stored as a comment in the stack file.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Library Host Path</label>
|
||||
<InputText @bind-Value="model.LibraryHostPath" class="form-control" placeholder="/data/xibo-library" />
|
||||
<label class="form-label fw-semibold">3-Letter Abbreviation</label>
|
||||
<div class="input-group">
|
||||
<InputText @bind-Value="model.CustomerAbbrev" class="form-control text-uppercase"
|
||||
placeholder="ACM" maxlength="3" style="text-transform:uppercase;" />
|
||||
<span class="input-group-text text-muted" style="font-size:0.85em;">
|
||||
Stack: <strong class="ms-1">@(model.CustomerAbbrev?.ToLowerInvariant() ?? "…")</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Exactly 3 letters (a–z). Used as the prefix for all stack resources:
|
||||
services, volumes, and network names.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* SMTP *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">SMTP Settings</div>
|
||||
<div class="card-body">
|
||||
@* Show what will be auto-configured from settings *@
|
||||
<div class="card mb-3 border-secondary">
|
||||
<div class="card-header bg-light text-muted">Auto-configured from Settings</div>
|
||||
<div class="card-body text-muted small">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">SMTP Server</label>
|
||||
<InputText @bind-Value="model.SmtpServer" class="form-control" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">SMTP Username</label>
|
||||
<InputText @bind-Value="model.SmtpUsername" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">SMTP Password</label>
|
||||
<InputText @bind-Value="model.SmtpPassword" class="form-control" type="password" />
|
||||
</div>
|
||||
<div class="col-6 mb-1"><strong>CMS server:</strong><br />@(Defaults.Value.CmsServerNameTemplate.Replace("{abbrev}", AbbrevPreview))</div>
|
||||
<div class="col-6 mb-1"><strong>Theme path:</strong><br />@Defaults.Value.ThemeHostPath</div>
|
||||
<div class="col-6 mb-1"><strong>MySQL DB:</strong><br />@(Defaults.Value.MySqlDatabaseTemplate.Replace("{abbrev}", AbbrevPreview))</div>
|
||||
<div class="col-6 mb-1"><strong>MySQL user:</strong><br />@(Defaults.Value.MySqlUserTemplate.Replace("{abbrev}", AbbrevPreview))</div>
|
||||
<div class="col-6 mb-1"><strong>SMTP server:</strong><br />@Defaults.Value.SmtpServer</div>
|
||||
<div class="col-6 mb-1"><strong>Template repo:</strong><br />@(string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl) ? "⚠️ Not configured" : Defaults.Value.TemplateRepoUrl)</div>
|
||||
</div>
|
||||
<a href="/settings" class="small">Edit defaults in Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Optional overrides *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Optional Overrides</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Template Repo URL <span class="text-muted">(overrides setting)</span></label>
|
||||
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control"
|
||||
placeholder="Leave blank to use configured default" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Theme Host Path <span class="text-muted">(overrides setting default: /cms/ots-theme)</span></label>
|
||||
<InputText @bind-Value="model.ThemeHostPath" class="form-control"
|
||||
placeholder="/cms/ots-theme" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Placement Constraints <span class="text-muted">(comma-separated)</span></label>
|
||||
<InputText @bind-Value="constraintsText" class="form-control"
|
||||
placeholder="node.labels.xibo==true, node.role==manager" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Git Template *@
|
||||
@* Xibo Credentials (optional) *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Git Template Source</div>
|
||||
<div class="card-header">Xibo API Credentials <span class="text-muted fw-normal">(optional)</span></div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label class="form-label">Template Repo URL</label>
|
||||
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control" placeholder="https://github.com/org/xibo-templates.git" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">PAT / Token (optional)</label>
|
||||
<InputText @bind-Value="model.TemplateRepoPat" class="form-control" type="password" placeholder="Leave empty for public repos" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Xibo Credentials *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Xibo API Credentials (optional)</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Provide credentials to enable API connectivity testing. You can add these later.</p>
|
||||
<p class="text-muted small">Provide credentials to enable API connectivity testing after deploy.</p>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Xibo Username (Client ID)</label>
|
||||
<InputText @bind-Value="model.XiboUsername" class="form-control" placeholder="Optional" />
|
||||
<label class="form-label">Client ID</label>
|
||||
<InputText @bind-Value="model.XiboUsername" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Xibo Password (Client Secret)</label>
|
||||
<InputText @bind-Value="model.XiboPassword" class="form-control" type="password" placeholder="Optional" />
|
||||
<label class="form-label">Client Secret</label>
|
||||
<InputText @bind-Value="model.XiboPassword" class="form-control" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
@if (xiboTestResult != null)
|
||||
{
|
||||
<div class="alert @(xiboTestResult.IsValid ? "alert-success" : "alert-danger")">
|
||||
@xiboTestResult.Message
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Constraints *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Placement Constraints (optional)</div>
|
||||
<div class="card-body">
|
||||
<InputText @bind-Value="constraintsText" class="form-control"
|
||||
placeholder="node.labels.xibo==true, node.role==manager" />
|
||||
<small class="text-muted">Comma-separated placement constraints</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Actions *@
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@deploying">
|
||||
@(deploying ? "Deploying..." : "Deploy Instance")
|
||||
@(deploying ? "Deploying…" : "Deploy Instance")
|
||||
</button>
|
||||
<a href="/" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
@@ -160,26 +391,39 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private CreateInstanceDto model = new()
|
||||
{
|
||||
HostHttpPort = 8080
|
||||
};
|
||||
private CreateInstanceDto model = new();
|
||||
|
||||
private string? constraintsText;
|
||||
private bool deploying;
|
||||
private string? resultMessage;
|
||||
private bool resultSuccess;
|
||||
private Guid? createdInstanceId;
|
||||
private XiboTestResult? xiboTestResult = null!;
|
||||
|
||||
/// <summary>Live abbrev preview (lowercase, 3 chars max) for the settings preview card.</summary>
|
||||
private string AbbrevPreview =>
|
||||
string.IsNullOrWhiteSpace(model.CustomerAbbrev)
|
||||
? "???"
|
||||
: model.CustomerAbbrev.ToLowerInvariant()[..Math.Min(3, model.CustomerAbbrev.Length)];
|
||||
|
||||
private void OnCustomerNameInput(ChangeEventArgs e)
|
||||
{
|
||||
// Auto-suggest abbreviation from first 3 letters of first word
|
||||
if (string.IsNullOrWhiteSpace(model.CustomerAbbrev))
|
||||
{
|
||||
var word = (e.Value?.ToString() ?? "").Split(' ', '-', '_').FirstOrDefault(w => w.Length > 0);
|
||||
if (!string.IsNullOrEmpty(word))
|
||||
model.CustomerAbbrev = word[..Math.Min(3, word.Length)].ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
deploying = true;
|
||||
resultMessage = null;
|
||||
model.CustomerAbbrev = model.CustomerAbbrev?.ToUpperInvariant() ?? string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
// Parse constraints
|
||||
if (!string.IsNullOrWhiteSpace(constraintsText))
|
||||
{
|
||||
model.Constraints = constraintsText
|
||||
@@ -198,7 +442,7 @@
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
var instance = (await InstanceSvc.ListInstancesAsync(1, 1, model.StackName)).Items.FirstOrDefault();
|
||||
var instance = (await InstanceSvc.ListInstancesAsync(1, 1, model.CustomerAbbrev.ToLowerInvariant())).Items.FirstOrDefault();
|
||||
createdInstanceId = instance?.Id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
@page "/instances/{Id:guid}/edit"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
|
||||
@inject InstanceService InstanceSvc
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Edit Instance - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
@if (instance == null)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h3>Edit: @instance.StackName</h3>
|
||||
<p class="text-muted">Customer: @instance.CustomerName — Stack and customer name cannot be changed.</p>
|
||||
|
||||
@if (errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
@if (successMessage != null)
|
||||
{
|
||||
<div class="alert alert-success">@successMessage</div>
|
||||
}
|
||||
|
||||
<EditForm Model="dto" OnValidSubmit="HandleSubmit" FormName="EditInstance">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary />
|
||||
|
||||
<h5 class="mt-3">Template Repository</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Template Repo URL</label>
|
||||
<InputText @bind-Value="dto.TemplateRepoUrl" class="form-control" />
|
||||
<small class="text-muted">Current: @instance.TemplateRepoUrl</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">PAT (leave blank to keep existing)</label>
|
||||
<InputText @bind-Value="dto.TemplateRepoPat" class="form-control" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>SMTP</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">SMTP Server</label>
|
||||
<InputText @bind-Value="dto.SmtpServer" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">SMTP Username</label>
|
||||
<InputText @bind-Value="dto.SmtpUsername" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>Placement Constraints</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Constraints (comma-separated)</label>
|
||||
<InputText @bind-Value="constraintsText" class="form-control" placeholder="e.g., node.labels.customer==acme" />
|
||||
<small class="text-muted">Current: @(instance.Constraints ?? "default")</small>
|
||||
</div>
|
||||
|
||||
<h5>Xibo API Credentials</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Xibo Username</label>
|
||||
<InputText @bind-Value="dto.XiboUsername" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Xibo Password (leave blank to keep existing)</label>
|
||||
<InputText @bind-Value="dto.XiboPassword" class="form-control" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary" disabled="@saving">
|
||||
@(saving ? "Updating..." : "Update Instance")
|
||||
</button>
|
||||
<a href="instances/@Id" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
private CmsInstance? instance;
|
||||
private UpdateInstanceDto dto = new();
|
||||
private string? constraintsText;
|
||||
private bool saving;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
instance = await InstanceSvc.GetInstanceAsync(Id);
|
||||
if (instance == null)
|
||||
{
|
||||
Navigation.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-fill mutable fields
|
||||
dto.TemplateRepoUrl = instance.TemplateRepoUrl;
|
||||
dto.SmtpServer = instance.SmtpServer;
|
||||
dto.SmtpUsername = instance.SmtpUsername;
|
||||
dto.XiboUsername = instance.XiboUsername;
|
||||
constraintsText = instance.Constraints;
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
saving = true;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(constraintsText))
|
||||
dto.Constraints = constraintsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
else
|
||||
dto.Constraints = null;
|
||||
|
||||
var result = await InstanceSvc.UpdateInstanceAsync(Id, dto);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
successMessage = result.Message;
|
||||
instance = await InstanceSvc.GetInstanceAsync(Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = result.Message;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
@page "/"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@inject InstanceService InstanceSvc
|
||||
@inject DockerCliService DockerCli
|
||||
|
||||
<PageTitle>Dashboard - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
<h2>CMS Instances</h2>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" placeholder="Filter by name..."
|
||||
@bind="filterText" @bind:event="oninput" @bind:after="LoadInstances" />
|
||||
</div>
|
||||
<div class="col-md-8 text-end">
|
||||
<AuthorizeView Roles="Admin">
|
||||
<a href="instances/create" class="btn btn-primary">+ New Instance</a>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (instances == null || instances.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No instances found. Create your first CMS instance to get started.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<span class="badge bg-success me-2">Active: @instances.Count(i => i.Status == InstanceStatus.Active)</span>
|
||||
<span class="badge bg-primary me-2">Deploying: @instances.Count(i => i.Status == InstanceStatus.Deploying)</span>
|
||||
<span class="badge bg-danger me-2">Error: @instances.Count(i => i.Status == InstanceStatus.Error)</span>
|
||||
<span class="text-muted ms-2">Total: @totalCount</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Stack</th>
|
||||
<th>Server</th>
|
||||
<th>Port</th>
|
||||
<th>Status</th>
|
||||
<th>Xibo API</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var inst in instances)
|
||||
{
|
||||
<tr>
|
||||
<td>@inst.CustomerName</td>
|
||||
<td><code>@inst.StackName</code></td>
|
||||
<td>@inst.CmsServerName</td>
|
||||
<td>@inst.HostHttpPort</td>
|
||||
<td>
|
||||
<span class="badge @GetStatusClass(inst.Status)">@inst.Status</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge @GetXiboStatusClass(inst.XiboApiTestStatus)">@inst.XiboApiTestStatus</span>
|
||||
</td>
|
||||
<td>@inst.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>
|
||||
<a href="instances/@inst.Id" class="btn btn-sm btn-outline-primary">View</a>
|
||||
<AuthorizeView Roles="Admin">
|
||||
<a href="instances/@inst.Id/edit" class="btn btn-sm btn-outline-secondary">Edit</a>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(inst)">Delete</button>
|
||||
</AuthorizeView>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (showDeleteModal)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" @onclick="() => showDeleteModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Delete stack <strong>@deleteTarget?.StackName</strong>?</p>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" @bind="retainSecrets" id="retainSecrets">
|
||||
<label class="form-check-label" for="retainSecrets">Retain Docker secrets</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="clearXiboCreds" id="clearCreds">
|
||||
<label class="form-check-label" for="clearCreds">Clear stored Xibo credentials</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="() => showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @onclick="ExecuteDelete" disabled="@deleting">
|
||||
@(deleting ? "Deleting..." : "Delete")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(statusMessage))
|
||||
{
|
||||
<div class="alert @(statusSuccess ? "alert-success" : "alert-danger") mt-3">
|
||||
@statusMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<CmsInstance>? instances;
|
||||
private int totalCount;
|
||||
private bool loading = true;
|
||||
private string? filterText;
|
||||
|
||||
private bool showDeleteModal;
|
||||
private CmsInstance? deleteTarget;
|
||||
private bool retainSecrets;
|
||||
private bool clearXiboCreds = true;
|
||||
private bool deleting;
|
||||
|
||||
private string? statusMessage;
|
||||
private bool statusSuccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadInstances();
|
||||
}
|
||||
|
||||
private async Task LoadInstances()
|
||||
{
|
||||
loading = true;
|
||||
var (items, total) = await InstanceSvc.ListInstancesAsync(1, 100, filterText);
|
||||
instances = items;
|
||||
totalCount = total;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private void ConfirmDelete(CmsInstance inst)
|
||||
{
|
||||
deleteTarget = inst;
|
||||
retainSecrets = false;
|
||||
clearXiboCreds = true;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
private async Task ExecuteDelete()
|
||||
{
|
||||
if (deleteTarget == null) return;
|
||||
|
||||
deleting = true;
|
||||
try
|
||||
{
|
||||
var result = await InstanceSvc.DeleteInstanceAsync(deleteTarget.Id, retainSecrets, clearXiboCreds);
|
||||
statusMessage = result.Success
|
||||
? $"Instance '{deleteTarget.StackName}' deleted."
|
||||
: $"Delete failed: {result.ErrorMessage}";
|
||||
statusSuccess = result.Success;
|
||||
showDeleteModal = false;
|
||||
await LoadInstances();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
statusMessage = $"Error: {ex.Message}";
|
||||
statusSuccess = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetStatusClass(InstanceStatus status) => status switch
|
||||
{
|
||||
InstanceStatus.Active => "bg-success",
|
||||
InstanceStatus.Deploying => "bg-primary",
|
||||
InstanceStatus.Error => "bg-danger",
|
||||
InstanceStatus.Deleted => "bg-secondary",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private static string GetXiboStatusClass(XiboApiTestStatus status) => status switch
|
||||
{
|
||||
XiboApiTestStatus.Success => "bg-success",
|
||||
XiboApiTestStatus.Failed => "bg-danger",
|
||||
XiboApiTestStatus.Unknown => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
@page "/login"
|
||||
@inject NavigationManager Navigation
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
|
||||
<PageTitle>Login - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="card-title text-center mb-4">OTS Signs Orchestrator</h3>
|
||||
|
||||
<hr />
|
||||
|
||||
<h5 class="mb-3">Admin Token Login</h5>
|
||||
<EditForm Model="loginModel" OnValidSubmit="SubmitToken" FormName="tokenLogin">
|
||||
<div class="mb-3">
|
||||
<label for="token" class="form-label">Admin Token</label>
|
||||
<InputText id="token" @bind-Value="loginModel.Token" class="form-control" type="password"
|
||||
placeholder="Enter admin token" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100" disabled="@submitting">
|
||||
@(submitting ? "Authenticating..." : "Sign In")
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3">@errorMessage</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private TokenLoginModel loginModel = new();
|
||||
private bool submitting;
|
||||
private string? errorMessage;
|
||||
|
||||
private async Task SubmitToken()
|
||||
{
|
||||
submitting = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var client = HttpClientFactory.CreateClient();
|
||||
client.BaseAddress = new Uri(Navigation.BaseUri);
|
||||
|
||||
var response = await client.PostAsJsonAsync("api/auth/verify-token", new { token = loginModel.Token });
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = "Invalid token. Please try again.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Login failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class TokenLoginModel
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
@page "/instances/{Id:guid}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@inject InstanceService InstanceSvc
|
||||
@inject DockerCliService DockerCli
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Instance Details - OTS Signs Orchestrator</PageTitle>
|
||||
|
||||
@if (instance == null)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h3>@instance.StackName</h3>
|
||||
<span class="text-muted">Customer: @instance.CustomerName</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge @GetStatusClass(instance.Status) fs-6">@instance.Status</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "info" ? "active" : "")" @onclick='() => activeTab = "info"'>Info</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "xibo" ? "active" : "")" @onclick='() => activeTab = "xibo"'>Xibo Status</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "services" ? "active" : "")" @onclick='() => LoadServices()'>Services</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "compose" ? "active" : "")" @onclick='() => activeTab = "compose"'>Compose</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@* Info Tab *@
|
||||
@if (activeTab == "info")
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table class="table">
|
||||
<tr><th>CMS Server Name</th><td>@instance.CmsServerName</td></tr>
|
||||
<tr><th>HTTP Port</th><td>@instance.HostHttpPort</td></tr>
|
||||
<tr><th>Theme Path</th><td><code>@instance.ThemeHostPath</code></td></tr>
|
||||
<tr><th>Library Path</th><td><code>@instance.LibraryHostPath</code></td></tr>
|
||||
<tr><th>SMTP Server</th><td>@instance.SmtpServer</td></tr>
|
||||
<tr><th>SMTP User</th><td>@instance.SmtpUsername</td></tr>
|
||||
<tr><th>Template Repo</th><td><small>@instance.TemplateRepoUrl</small></td></tr>
|
||||
<tr><th>Last Fetch</th><td>@instance.TemplateLastFetch?.ToString("yyyy-MM-dd HH:mm")</td></tr>
|
||||
<tr><th>Constraints</th><td><code>@(instance.Constraints ?? "default")</code></td></tr>
|
||||
<tr><th>Created</th><td>@instance.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td></tr>
|
||||
<tr><th>Updated</th><td>@instance.UpdatedAt.ToString("yyyy-MM-dd HH:mm")</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Xibo Status Tab *@
|
||||
@if (activeTab == "xibo")
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5>Xibo API Connection</h5>
|
||||
<p>
|
||||
Status:
|
||||
<span class="badge @GetXiboStatusClass(instance.XiboApiTestStatus)">@instance.XiboApiTestStatus</span>
|
||||
@if (instance.XiboApiTestedAt.HasValue)
|
||||
{
|
||||
<small class="text-muted ms-2">tested @instance.XiboApiTestedAt.Value.ToString("yyyy-MM-dd HH:mm")</small>
|
||||
}
|
||||
</p>
|
||||
<p>Username: <code>@(instance.XiboUsername ?? "Not set")</code></p>
|
||||
|
||||
<AuthorizeView Roles="Admin">
|
||||
<button class="btn btn-outline-primary" @onclick="TestXiboConnection" disabled="@testingXibo">
|
||||
@(testingXibo ? "Testing..." : "Re-test Connection")
|
||||
</button>
|
||||
</AuthorizeView>
|
||||
|
||||
@if (xiboTestResult != null)
|
||||
{
|
||||
<div class="alert @(xiboTestResult.IsValid ? "alert-success" : "alert-danger") mt-2">
|
||||
@xiboTestResult.Message
|
||||
</div>
|
||||
}
|
||||
|
||||
<hr />
|
||||
<p class="text-muted">Future: Layouts, Displays, Scheduling management (coming soon)</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Services Tab *@
|
||||
@if (activeTab == "services")
|
||||
{
|
||||
@if (services == null)
|
||||
{
|
||||
<p>Loading services...</p>
|
||||
}
|
||||
else if (services.Count == 0)
|
||||
{
|
||||
<div class="alert alert-warning">No services found for this stack.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead><tr><th>Service</th><th>Image</th><th>Replicas</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var svc in services)
|
||||
{
|
||||
<tr>
|
||||
<td>@svc.Name</td>
|
||||
<td><code>@svc.Image</code></td>
|
||||
<td>@svc.Replicas</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
@* Compose Tab *@
|
||||
@if (activeTab == "compose")
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Rendered Compose YAML (read-only). Re-generate by editing and updating the instance.</p>
|
||||
<pre class="bg-dark text-light p-3 rounded" style="max-height: 600px; overflow-y: auto;">
|
||||
<code>@composeYaml</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Actions *@
|
||||
<div class="mt-4">
|
||||
<AuthorizeView Roles="Admin">
|
||||
<a href="instances/@instance.Id/edit" class="btn btn-primary me-2">Edit Instance</a>
|
||||
</AuthorizeView>
|
||||
<a href="/" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
private CmsInstance? instance;
|
||||
private string activeTab = "info";
|
||||
private List<ServiceInfo>? services;
|
||||
private string? composeYaml = "Compose YAML will be regenerated when you update the instance.";
|
||||
|
||||
private bool testingXibo;
|
||||
private XiboTestResult? xiboTestResult;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
instance = await InstanceSvc.GetInstanceAsync(Id);
|
||||
if (instance == null)
|
||||
Navigation.NavigateTo("/");
|
||||
}
|
||||
|
||||
private async Task LoadServices()
|
||||
{
|
||||
activeTab = "services";
|
||||
if (instance != null)
|
||||
{
|
||||
services = await DockerCli.InspectStackServicesAsync(instance.StackName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TestXiboConnection()
|
||||
{
|
||||
testingXibo = true;
|
||||
xiboTestResult = null;
|
||||
|
||||
try
|
||||
{
|
||||
xiboTestResult = await InstanceSvc.TestXiboConnectionAsync(Id);
|
||||
// Reload instance to get updated test status
|
||||
instance = await InstanceSvc.GetInstanceAsync(Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
xiboTestResult = new XiboTestResult { IsValid = false, Message = ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
testingXibo = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetStatusClass(InstanceStatus status) => status switch
|
||||
{
|
||||
InstanceStatus.Active => "bg-success",
|
||||
InstanceStatus.Deploying => "bg-primary",
|
||||
InstanceStatus.Error => "bg-danger",
|
||||
InstanceStatus.Deleted => "bg-secondary",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private static string GetXiboStatusClass(XiboApiTestStatus status) => status switch
|
||||
{
|
||||
XiboApiTestStatus.Success => "bg-success",
|
||||
XiboApiTestStatus.Failed => "bg-danger",
|
||||
XiboApiTestStatus.Unknown => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -1,15 +0,0 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.JSInterop
|
||||
@using OTSSignsOrchestrator
|
||||
@using OTSSignsOrchestrator.Components
|
||||
@using OTSSignsOrchestrator.Models.Entities
|
||||
@using OTSSignsOrchestrator.Models.DTOs
|
||||
@using OTSSignsOrchestrator.Services
|
||||
@using OTSSignsOrchestrator.Configuration
|
||||
@@ -53,3 +53,64 @@ public class DatabaseOptions
|
||||
public const string SectionName = "Database";
|
||||
public string Provider { get; set; } = "Sqlite"; // Sqlite or PostgreSQL
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admin-level MySQL connection used by the orchestrator to provision new customer databases.
|
||||
/// Credentials are stored in app settings (encrypted at rest via Data Protection where available).
|
||||
/// The generated per-customer password is NEVER stored here — it is placed directly into a Docker secret.
|
||||
/// </summary>
|
||||
public class MySqlAdminOptions
|
||||
{
|
||||
public const string SectionName = "MySqlAdmin";
|
||||
public string Host { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 3306;
|
||||
public string AdminUser { get; set; } = "root";
|
||||
public string AdminPassword { get; set; } = string.Empty;
|
||||
/// <summary>If true, treat TLS/cert errors as non-fatal (useful for self-signed certs in dev).</summary>
|
||||
public bool AllowInsecureTls { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CIFS volume settings applied to every named Docker volume created for a new instance.
|
||||
/// The credentials file on the remote host is written ephemerally via SSH and deleted immediately after
|
||||
/// the docker volume create command completes.
|
||||
/// </summary>
|
||||
public class CifsOptions
|
||||
{
|
||||
public const string SectionName = "Cifs";
|
||||
/// <summary>UNC-style device path, e.g. //fileserver.local/xibo-data</summary>
|
||||
public string Device { get; set; } = string.Empty;
|
||||
/// <summary>Hostname/IP of the CIFS server for the addr= mount option.</summary>
|
||||
public string ServerAddr { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public string MountOptions { get; set; } = "vers=3.0,file_mode=0660,dir_mode=0770";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defaults sourced from the Settings page, used to pre-populate or complete instance creation
|
||||
/// without requiring the operator to retype them every time.
|
||||
/// </summary>
|
||||
public class InstanceDefaultsOptions
|
||||
{
|
||||
public const string SectionName = "InstanceDefaults";
|
||||
/// <summary>Default Git template repo URL (operator can override per-instance).</summary>
|
||||
public string TemplateRepoUrl { get; set; } = string.Empty;
|
||||
public string? TemplateRepoPat { get; set; }
|
||||
/// <summary>Template for CMS_SERVER_NAME. Use {abbrev} as placeholder, e.g. "{abbrev}x.ots-signs.com".</summary>
|
||||
public string CmsServerNameTemplate { get; set; } = "{abbrev}.ots-signs.com";
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
public string SmtpUsername { get; set; } = string.Empty;
|
||||
public string SmtpPassword { get; set; } = string.Empty;
|
||||
/// <summary>Base host HTTP port; each new instance auto-increments from this value.</summary>
|
||||
public int BaseHostHttpPort { get; set; } = 8080;
|
||||
/// <summary>Template for the theme host path. Use {abbrev} as placeholder.</summary>
|
||||
/// <summary>Static host path for the theme volume mount. Overridable per-instance.</summary>
|
||||
public string ThemeHostPath { get; set; } = "/cms/ots-theme";
|
||||
/// <summary>Template for the library CIFS volume sub-path. Use {abbrev} as placeholder.</summary>
|
||||
public string LibraryShareSubPath { get; set; } = "{abbrev}-cms-library";
|
||||
/// <summary>MySQL database name template. Use {abbrev}.</summary>
|
||||
public string MySqlDatabaseTemplate { get; set; } = "{abbrev}_cms_db";
|
||||
/// <summary>MySQL username template. Use {abbrev}.</summary>
|
||||
public string MySqlUserTemplate { get; set; } = "{abbrev}_cms";
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ public static class DependencyInjection
|
||||
builder.Services.Configure<DockerOptions>(config.GetSection(DockerOptions.SectionName));
|
||||
builder.Services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
||||
builder.Services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
|
||||
builder.Services.Configure<MySqlAdminOptions>(config.GetSection(MySqlAdminOptions.SectionName));
|
||||
builder.Services.Configure<CifsOptions>(config.GetSection(CifsOptions.SectionName));
|
||||
builder.Services.Configure<InstanceDefaultsOptions>(config.GetSection(InstanceDefaultsOptions.SectionName));
|
||||
|
||||
// --- Serilog ---
|
||||
ConfigureSerilog(builder);
|
||||
@@ -66,6 +69,7 @@ public static class DependencyInjection
|
||||
builder.Services.AddScoped<ComposeValidationService>();
|
||||
builder.Services.AddScoped<DockerCliService>();
|
||||
builder.Services.AddScoped<DockerSecretsService>();
|
||||
builder.Services.AddScoped<MySqlProvisionService>();
|
||||
builder.Services.AddScoped<XiboApiService>();
|
||||
builder.Services.AddScoped<InstanceService>();
|
||||
builder.Services.AddScoped<OidcProviderService>();
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# ==============================================================================
|
||||
# OTSSignsOrchestrator - Multi-stage Dockerfile
|
||||
# ==============================================================================
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy csproj and restore
|
||||
COPY OTSSignsOrchestrator.csproj ./
|
||||
RUN dotnet restore
|
||||
|
||||
# Copy everything else and publish
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app/publish --no-restore
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 2: Runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Install Docker CLI for stack deploy/rm/ls commands
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
docker.io \
|
||||
git \
|
||||
ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user (will need docker group for socket access)
|
||||
RUN groupadd -r xiboapp && \
|
||||
useradd -r -g xiboapp -d /app -s /sbin/nologin xiboapp
|
||||
|
||||
# Copy published app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# Create directories for logs, data, and template cache
|
||||
RUN mkdir -p /app/logs /app/data /app/template-cache && \
|
||||
chown -R xiboapp:xiboapp /app
|
||||
|
||||
# Expose port (Kestrel default in .NET 9)
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/healthz || exit 1
|
||||
|
||||
# Switch to non-root user
|
||||
USER xiboapp
|
||||
|
||||
# Environment defaults
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
|
||||
ENTRYPOINT ["dotnet", "OTSSignsOrchestrator.dll"]
|
||||
@@ -4,39 +4,27 @@ namespace OTSSignsOrchestrator.Models.DTOs;
|
||||
|
||||
public class CreateInstanceDto
|
||||
{
|
||||
/// <summary>Full display name of the customer (stored as YAML comment).</summary>
|
||||
[Required, MaxLength(100)]
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string StackName { get; set; } = string.Empty;
|
||||
/// <summary>3-letter uppercase abbreviation used as prefix in all stack resource names.</summary>
|
||||
[Required, StringLength(3, MinimumLength = 3, ErrorMessage = "Abbreviation must be exactly 3 letters.")]
|
||||
[RegularExpression("^[a-zA-Z]{3}$", ErrorMessage = "Abbreviation must be 3 letters (a-z, A-Z).")]
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
|
||||
[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;
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string SmtpPassword { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string TemplateRepoUrl { get; set; } = string.Empty;
|
||||
// TemplateRepoUrl and TemplateRepoPat are sourced from app settings (Settings page) and
|
||||
// optionally overridden here per-instance.
|
||||
[MaxLength(500)]
|
||||
public string? TemplateRepoUrl { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? TemplateRepoPat { get; set; }
|
||||
|
||||
/// <summary>Override the theme host path from settings (e.g. /cms/ots-theme).</summary>
|
||||
[MaxLength(500)]
|
||||
public string? ThemeHostPath { get; set; }
|
||||
|
||||
/// <summary>Comma-separated placement constraints.</summary>
|
||||
public List<string>? Constraints { get; set; }
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OTSSignsOrchestrator.Models.DTOs;
|
||||
|
||||
public class CreateOidcProviderDto
|
||||
{
|
||||
[Required, MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? Audience { get; set; }
|
||||
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public bool IsPrimary { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateOidcProviderDto
|
||||
{
|
||||
[MaxLength(100)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? Authority { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? Audience { get; set; }
|
||||
|
||||
public bool? IsEnabled { get; set; }
|
||||
|
||||
public bool? IsPrimary { get; set; }
|
||||
}
|
||||
@@ -26,6 +26,10 @@ public class CmsInstance
|
||||
[Required, MaxLength(100)]
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>3-letter lowercase abbreviation used as stack resource prefix (e.g. "ots").</summary>
|
||||
[Required, MaxLength(3)]
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string StackName { get; set; } = string.Empty;
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OTSSignsOrchestrator.Models.Entities;
|
||||
|
||||
public class OidcProvider
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(500)]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypted via Data Protection. Never logged.
|
||||
/// </summary>
|
||||
[Required, MaxLength(2000)]
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? Audience { get; set; }
|
||||
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public bool IsPrimary { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using OTSSignsOrchestrator.Components;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add OTS Signs Orchestrator services (DB, Auth, Logging, Docker, Git, etc.)
|
||||
builder.AddXiboSwarmServices();
|
||||
|
||||
// Add Blazor components
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Apply EF Core migrations on startup
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Apply Xibo Swarm middleware (security headers, auth, healthcheck, controllers)
|
||||
app.UseXiboSwarmMiddleware();
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.Run();
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5230",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7157;http://localhost:5230",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
|
||||
namespace OTSSignsOrchestrator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates requests using a static bearer token (for bootstrap / recovery).
|
||||
/// </summary>
|
||||
public class AdminTokenAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly string _expectedToken;
|
||||
|
||||
public AdminTokenAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IOptions<Configuration.AuthenticationOptions> authOptions)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_expectedToken = authOptions.Value.LocalAdminToken;
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_expectedToken))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
string? authorization = Request.Headers.Authorization.FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(authorization))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
if (!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
var token = authorization["Bearer ".Length..].Trim();
|
||||
if (!string.Equals(token, _expectedToken, StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.Fail("Invalid admin token."));
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, "LocalAdmin"),
|
||||
new Claim(ClaimTypes.Role, AppConstants.AdminRole),
|
||||
new Claim("auth_method", "admin_token")
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -9,22 +9,27 @@ using YamlDotNet.Serialization.NamingConventions;
|
||||
namespace OTSSignsOrchestrator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a Compose v3.8 YAML from a template, merged with user inputs,
|
||||
/// secrets references, bind mounts, and placement constraints.
|
||||
/// Renders a single self-contained Compose v3.9 YAML from the template and user/settings inputs.
|
||||
/// The output file has no separate env_file — all environment variables are inlined.
|
||||
/// Service and volume names are prefixed with the 3-letter customer abbreviation.
|
||||
/// CIFS-backed named volumes are rendered with driver_opts so they can be created on the swarm.
|
||||
/// </summary>
|
||||
public class ComposeRenderService
|
||||
{
|
||||
private readonly XiboOptions _xiboOptions;
|
||||
private readonly DockerOptions _dockerOptions;
|
||||
private readonly CifsOptions _cifsOptions;
|
||||
private readonly ILogger<ComposeRenderService> _logger;
|
||||
|
||||
public ComposeRenderService(
|
||||
IOptions<XiboOptions> xiboOptions,
|
||||
IOptions<DockerOptions> dockerOptions,
|
||||
IOptions<CifsOptions> cifsOptions,
|
||||
ILogger<ComposeRenderService> logger)
|
||||
{
|
||||
_xiboOptions = xiboOptions.Value;
|
||||
_dockerOptions = dockerOptions.Value;
|
||||
_cifsOptions = cifsOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -33,7 +38,7 @@ public class ComposeRenderService
|
||||
/// </summary>
|
||||
public string Render(RenderContext ctx)
|
||||
{
|
||||
_logger.LogInformation("Rendering Compose for stack: {StackName}", ctx.StackName);
|
||||
_logger.LogInformation("Rendering Compose for stack: {StackName} (abbrev={Abbrev})", ctx.StackName, ctx.CustomerAbbrev);
|
||||
|
||||
// Parse template YAML
|
||||
var yaml = new YamlStream();
|
||||
@@ -45,7 +50,7 @@ public class ComposeRenderService
|
||||
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
|
||||
|
||||
// Ensure version
|
||||
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.8");
|
||||
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
|
||||
|
||||
// Process services
|
||||
EnsureServices(root, ctx);
|
||||
@@ -57,17 +62,16 @@ public class ComposeRenderService
|
||||
EnsureSecrets(root, ctx);
|
||||
|
||||
// Serialize back to YAML
|
||||
var serializer = new SerializerBuilder()
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
using var writer = new StringWriter();
|
||||
yaml.Save(writer, assignAnchors: false);
|
||||
var output = writer.ToString();
|
||||
|
||||
// Clean up YAML artifacts
|
||||
// Clean up YAML stream terminators
|
||||
output = output.Replace("...\n", "").Replace("...", "");
|
||||
|
||||
// Prepend customer comment
|
||||
output = $"# Customer: {ctx.CustomerName}\n" + output;
|
||||
|
||||
_logger.LogDebug("Compose rendered: {ServiceCount} services, {SecretCount} secrets",
|
||||
GetServiceCount(root), ctx.SecretNames.Count);
|
||||
|
||||
@@ -81,257 +85,265 @@ public class ComposeRenderService
|
||||
|
||||
var services = (YamlMappingNode)root.Children[new YamlScalarNode("services")];
|
||||
|
||||
// CMS Database (MySQL)
|
||||
EnsureCmsDb(services, ctx);
|
||||
// Clear any services from template — we always build them deterministically
|
||||
services.Children.Clear();
|
||||
|
||||
// CMS Web (Xibo)
|
||||
EnsureCmsWeb(services, ctx);
|
||||
|
||||
// Memcached
|
||||
EnsureMemcached(services, ctx);
|
||||
|
||||
// QuickChart
|
||||
EnsureQuickChart(services, ctx);
|
||||
|
||||
// Remove XMR if present
|
||||
var xmrKey = new YamlScalarNode("cms-xmr");
|
||||
if (services.Children.ContainsKey(xmrKey))
|
||||
{
|
||||
services.Children.Remove(xmrKey);
|
||||
_logger.LogInformation("Removed cms-xmr service from compose (not needed for 4.4.0)");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureCmsDb(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var key = new YamlScalarNode("cms-db");
|
||||
YamlMappingNode svc;
|
||||
|
||||
if (services.Children.ContainsKey(key))
|
||||
svc = (YamlMappingNode)services.Children[key];
|
||||
else
|
||||
{
|
||||
svc = new YamlMappingNode();
|
||||
services.Children[key] = svc;
|
||||
}
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Mysql);
|
||||
|
||||
// Environment
|
||||
var env = new YamlMappingNode
|
||||
{
|
||||
{ "MYSQL_DATABASE", "cms" },
|
||||
{ "MYSQL_USER", "cms" },
|
||||
{ "MYSQL_PASSWORD_FILE", $"/run/secrets/{AppConstants.CustomerMysqlSecretName(ctx.CustomerName)}" },
|
||||
{ "MYSQL_RANDOM_ROOT_PASSWORD", "yes" }
|
||||
};
|
||||
svc.Children[new YamlScalarNode("environment")] = env;
|
||||
|
||||
// Volumes
|
||||
var volumes = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{AppConstants.SanitizeName(ctx.CustomerName)}_cms_db:/var/lib/mysql")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("volumes")] = volumes;
|
||||
|
||||
// Secrets
|
||||
var secrets = new YamlSequenceNode(
|
||||
new YamlScalarNode(AppConstants.CustomerMysqlSecretName(ctx.CustomerName))
|
||||
);
|
||||
svc.Children[new YamlScalarNode("secrets")] = secrets;
|
||||
|
||||
// Placement constraints
|
||||
ApplyConstraints(svc, ctx);
|
||||
EnsureNewt(services, ctx);
|
||||
}
|
||||
|
||||
private void EnsureCmsWeb(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var key = new YamlScalarNode("cms-web");
|
||||
YamlMappingNode svc;
|
||||
|
||||
if (services.Children.ContainsKey(key))
|
||||
svc = (YamlMappingNode)services.Children[key];
|
||||
else
|
||||
{
|
||||
svc = new YamlMappingNode();
|
||||
services.Children[key] = svc;
|
||||
}
|
||||
var a = ctx.CustomerAbbrev;
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{a}-web")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Cms);
|
||||
|
||||
// Merge template env with overrides
|
||||
var env = new YamlMappingNode();
|
||||
|
||||
// Apply template.env defaults first
|
||||
foreach (var line in ctx.TemplateEnvLines)
|
||||
{
|
||||
var eqIdx = line.IndexOf('=');
|
||||
if (eqIdx > 0)
|
||||
{
|
||||
var k = line[..eqIdx].Trim();
|
||||
var v = line[(eqIdx + 1)..].Trim();
|
||||
env.Children[new YamlScalarNode(k)] = new YamlScalarNode(v);
|
||||
}
|
||||
}
|
||||
|
||||
// Override with our required values
|
||||
env.Children[new YamlScalarNode("CMS_SERVER_NAME")] = new YamlScalarNode(ctx.CmsServerName);
|
||||
env.Children[new YamlScalarNode("MYSQL_HOST")] = new YamlScalarNode("cms-db");
|
||||
env.Children[new YamlScalarNode("MYSQL_DATABASE")] = new YamlScalarNode("cms");
|
||||
env.Children[new YamlScalarNode("MYSQL_USER")] = new YamlScalarNode("cms");
|
||||
env.Children[new YamlScalarNode("MYSQL_PASSWORD_FILE")] =
|
||||
new YamlScalarNode($"/run/secrets/{AppConstants.CustomerMysqlSecretName(ctx.CustomerName)}");
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_SERVER")] = new YamlScalarNode(ctx.SmtpServer);
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_USERNAME")] = new YamlScalarNode(ctx.SmtpUsername);
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_PASSWORD_FILE")] =
|
||||
new YamlScalarNode($"/run/secrets/{AppConstants.GlobalSmtpSecretName}");
|
||||
env.Children[new YamlScalarNode("MEMCACHED_HOST")] = new YamlScalarNode("cms-memcached");
|
||||
env.Children[new YamlScalarNode("QUICKCHART_API_URL")] = new YamlScalarNode("http://cms-quickchart:3400");
|
||||
|
||||
// Build environment — merge template.env first, then apply our required overrides
|
||||
var env = BuildEnvFromTemplate(ctx.TemplateEnvValues, ctx);
|
||||
svc.Children[new YamlScalarNode("environment")] = env;
|
||||
|
||||
// Ports
|
||||
var ports = new YamlSequenceNode(
|
||||
svc.Children[new YamlScalarNode("ports")] = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{ctx.HostHttpPort}:80")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("ports")] = ports;
|
||||
|
||||
// Volumes (bind mounts + named volumes)
|
||||
var volumes = new YamlSequenceNode(
|
||||
// Named volumes (CIFS-backed) + theme bind mount
|
||||
svc.Children[new YamlScalarNode("volumes")] = new YamlSequenceNode(
|
||||
new YamlScalarNode($"{a}-cms-custom:/var/www/cms/custom"),
|
||||
new YamlScalarNode($"{a}-cms-backup:/var/www/backup"),
|
||||
new YamlScalarNode($"{ctx.ThemeHostPath}:/var/www/cms/web/theme/custom"),
|
||||
new YamlScalarNode($"{ctx.LibraryHostPath}:/var/www/cms/library"),
|
||||
new YamlScalarNode($"{AppConstants.SanitizeName(ctx.CustomerName)}_cms_backup:/var/www/cms/backup"),
|
||||
new YamlScalarNode($"{AppConstants.SanitizeName(ctx.CustomerName)}_cms_custom:/var/www/cms/custom")
|
||||
new YamlScalarNode($"{a}-cms-library:/var/www/cms/library"),
|
||||
new YamlScalarNode($"{a}-cms-userscripts:/var/www/cms/web/userscripts"),
|
||||
new YamlScalarNode($"{a}-cms-ca-certs:/var/www/cms/ca-certs")
|
||||
);
|
||||
svc.Children[new YamlScalarNode("volumes")] = volumes;
|
||||
|
||||
// Secrets
|
||||
var secrets = new YamlSequenceNode(
|
||||
new YamlScalarNode(AppConstants.CustomerMysqlSecretName(ctx.CustomerName)),
|
||||
new YamlScalarNode(AppConstants.GlobalSmtpSecretName)
|
||||
);
|
||||
svc.Children[new YamlScalarNode("secrets")] = secrets;
|
||||
|
||||
// Depends on
|
||||
svc.Children[new YamlScalarNode("depends_on")] = new YamlSequenceNode(
|
||||
new YamlScalarNode("cms-db"),
|
||||
new YamlScalarNode("cms-memcached")
|
||||
svc.Children[new YamlScalarNode("secrets")] = new YamlSequenceNode(
|
||||
ctx.SecretNames.Select(n => (YamlNode)new YamlScalarNode(n)).ToList()
|
||||
);
|
||||
|
||||
ApplyConstraints(svc, ctx);
|
||||
// Network
|
||||
var netNode = new YamlMappingNode();
|
||||
var aliasNode = new YamlMappingNode();
|
||||
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("web"));
|
||||
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
|
||||
svc.Children[new YamlScalarNode("networks")] = netNode;
|
||||
|
||||
// Deploy
|
||||
var deploy = BuildDeploy(ctx, memoryLimit: "1G");
|
||||
svc.Children[new YamlScalarNode("deploy")] = deploy;
|
||||
}
|
||||
|
||||
private void EnsureMemcached(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var key = new YamlScalarNode("cms-memcached");
|
||||
YamlMappingNode svc;
|
||||
|
||||
if (services.Children.ContainsKey(key))
|
||||
svc = (YamlMappingNode)services.Children[key];
|
||||
else
|
||||
{
|
||||
svc = new YamlMappingNode();
|
||||
services.Children[key] = svc;
|
||||
}
|
||||
var a = ctx.CustomerAbbrev;
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{a}-memcached")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Memcached);
|
||||
svc.Children[new YamlScalarNode("command")] = new YamlSequenceNode(
|
||||
new YamlScalarNode("memcached"), new YamlScalarNode("-m"), new YamlScalarNode("15")
|
||||
);
|
||||
|
||||
ApplyConstraints(svc, ctx);
|
||||
var netNode = new YamlMappingNode();
|
||||
var aliasNode = new YamlMappingNode();
|
||||
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("memcached"));
|
||||
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
|
||||
svc.Children[new YamlScalarNode("networks")] = netNode;
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx, memoryLimit: "100M");
|
||||
}
|
||||
|
||||
private void EnsureQuickChart(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
var key = new YamlScalarNode("cms-quickchart");
|
||||
YamlMappingNode svc;
|
||||
|
||||
if (services.Children.ContainsKey(key))
|
||||
svc = (YamlMappingNode)services.Children[key];
|
||||
else
|
||||
{
|
||||
svc = new YamlMappingNode();
|
||||
services.Children[key] = svc;
|
||||
}
|
||||
var a = ctx.CustomerAbbrev;
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{a}-quickchart")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.QuickChart);
|
||||
|
||||
ApplyConstraints(svc, ctx);
|
||||
var netNode = new YamlMappingNode();
|
||||
var aliasNode = new YamlMappingNode();
|
||||
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("quickchart"));
|
||||
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
|
||||
svc.Children[new YamlScalarNode("networks")] = netNode;
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx);
|
||||
}
|
||||
|
||||
private void ApplyConstraints(YamlMappingNode service, RenderContext ctx)
|
||||
private void EnsureNewt(YamlMappingNode services, RenderContext ctx)
|
||||
{
|
||||
if (ctx.Constraints == null || ctx.Constraints.Count == 0)
|
||||
var a = ctx.CustomerAbbrev;
|
||||
// Only add newt if the template env provides the newt config
|
||||
if (!ctx.TemplateEnvValues.ContainsKey("NEWT_ID") && !ctx.TemplateEnvValues.ContainsKey("PANGOLIN_ENDPOINT"))
|
||||
return;
|
||||
|
||||
var deployKey = new YamlScalarNode("deploy");
|
||||
YamlMappingNode deploy;
|
||||
if (service.Children.ContainsKey(deployKey))
|
||||
deploy = (YamlMappingNode)service.Children[deployKey];
|
||||
else
|
||||
var svc = new YamlMappingNode();
|
||||
services.Children[new YamlScalarNode($"{a}-newt")] = svc;
|
||||
|
||||
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode("fosrl/newt");
|
||||
|
||||
var env = new YamlMappingNode();
|
||||
if (ctx.TemplateEnvValues.TryGetValue("PANGOLIN_ENDPOINT", out var endpoint))
|
||||
env.Children[new YamlScalarNode("PANGOLIN_ENDPOINT")] = new YamlScalarNode(endpoint);
|
||||
if (ctx.TemplateEnvValues.TryGetValue("NEWT_ID", out var newtId))
|
||||
env.Children[new YamlScalarNode("NEWT_ID")] = new YamlScalarNode(newtId);
|
||||
if (ctx.TemplateEnvValues.TryGetValue("NEWT_SECRET", out var newtSecret))
|
||||
env.Children[new YamlScalarNode("NEWT_SECRET")] = new YamlScalarNode(newtSecret);
|
||||
svc.Children[new YamlScalarNode("environment")] = env;
|
||||
|
||||
var netNode = new YamlMappingNode();
|
||||
netNode.Children[new YamlScalarNode($"{a}-net")] = new YamlMappingNode();
|
||||
svc.Children[new YamlScalarNode("networks")] = netNode;
|
||||
|
||||
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the environment mapping for cms-web: start with template.env values,
|
||||
/// then apply all required orchestrator overrides.
|
||||
/// </summary>
|
||||
private YamlMappingNode BuildEnvFromTemplate(Dictionary<string, string> templateEnv, RenderContext ctx)
|
||||
{
|
||||
var env = new YamlMappingNode();
|
||||
|
||||
// Apply template values first
|
||||
foreach (var (k, v) in templateEnv)
|
||||
env.Children[new YamlScalarNode(k)] = new YamlScalarNode(v);
|
||||
|
||||
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(ctx.CustomerAbbrev);
|
||||
|
||||
// Required overrides — these always win over template values
|
||||
env.Children[new YamlScalarNode("CMS_USE_MEMCACHED")] = new YamlScalarNode("true");
|
||||
env.Children[new YamlScalarNode("MEMCACHED_HOST")] = new YamlScalarNode("memcached");
|
||||
env.Children[new YamlScalarNode("CMS_SERVER_NAME")] = new YamlScalarNode(ctx.CmsServerName);
|
||||
env.Children[new YamlScalarNode("MYSQL_HOST")] = new YamlScalarNode(ctx.MySqlHost);
|
||||
env.Children[new YamlScalarNode("MYSQL_PORT")] = new YamlScalarNode(ctx.MySqlPort.ToString());
|
||||
env.Children[new YamlScalarNode("MYSQL_DATABASE")] = new YamlScalarNode(ctx.MySqlDatabase);
|
||||
env.Children[new YamlScalarNode("MYSQL_USER")] = new YamlScalarNode(ctx.MySqlUser);
|
||||
env.Children[new YamlScalarNode("MYSQL_PASSWORD")] = new YamlScalarNode($"/run/secrets/{mysqlSecretName}");
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_SERVER")] = new YamlScalarNode(ctx.SmtpServer);
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_USERNAME")] = new YamlScalarNode(ctx.SmtpUsername);
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_PASSWORD")] = new YamlScalarNode(ctx.SmtpPassword);
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_USE_TLS")] = new YamlScalarNode("YES");
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_USE_STARTTLS")] = new YamlScalarNode("YES");
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_REWRITE_DOMAIN")] = new YamlScalarNode(ctx.SmtpRewriteDomain);
|
||||
env.Children[new YamlScalarNode("CMS_SMTP_FROM_LINE_OVERRIDE")] = new YamlScalarNode("NO");
|
||||
env.Children[new YamlScalarNode("CMS_PHP_POST_MAX_SIZE")] = new YamlScalarNode("10G");
|
||||
env.Children[new YamlScalarNode("CMS_PHP_UPLOAD_MAX_FILESIZE")] = new YamlScalarNode("10G");
|
||||
env.Children[new YamlScalarNode("CMS_PHP_MAX_EXECUTION_TIME")] = new YamlScalarNode("600");
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
private static YamlMappingNode BuildDeploy(RenderContext ctx, string? memoryLimit = null)
|
||||
{
|
||||
var deploy = new YamlMappingNode();
|
||||
|
||||
var restartPolicy = new YamlMappingNode();
|
||||
restartPolicy.Children[new YamlScalarNode("condition")] = new YamlScalarNode("any");
|
||||
deploy.Children[new YamlScalarNode("restart_policy")] = restartPolicy;
|
||||
|
||||
if (memoryLimit != null)
|
||||
{
|
||||
deploy = new YamlMappingNode();
|
||||
service.Children[deployKey] = deploy;
|
||||
var resources = new YamlMappingNode();
|
||||
var limits = new YamlMappingNode();
|
||||
limits.Children[new YamlScalarNode("memory")] = new YamlScalarNode(memoryLimit);
|
||||
resources.Children[new YamlScalarNode("limits")] = limits;
|
||||
deploy.Children[new YamlScalarNode("resources")] = resources;
|
||||
}
|
||||
|
||||
var placementKey = new YamlScalarNode("placement");
|
||||
YamlMappingNode placement;
|
||||
if (deploy.Children.ContainsKey(placementKey))
|
||||
placement = (YamlMappingNode)deploy.Children[placementKey];
|
||||
else
|
||||
if (ctx.Constraints != null && ctx.Constraints.Count > 0)
|
||||
{
|
||||
placement = new YamlMappingNode();
|
||||
deploy.Children[placementKey] = placement;
|
||||
var placement = new YamlMappingNode();
|
||||
placement.Children[new YamlScalarNode("constraints")] = new YamlSequenceNode(
|
||||
ctx.Constraints.Select(c => (YamlNode)new YamlScalarNode(c)).ToList()
|
||||
);
|
||||
deploy.Children[new YamlScalarNode("placement")] = placement;
|
||||
}
|
||||
|
||||
var constraintNodes = ctx.Constraints
|
||||
.Select(c => (YamlNode)new YamlScalarNode(c))
|
||||
.ToList();
|
||||
|
||||
placement.Children[new YamlScalarNode("constraints")] = new YamlSequenceNode(constraintNodes);
|
||||
return deploy;
|
||||
}
|
||||
|
||||
private void EnsureVolumes(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var a = ctx.CustomerAbbrev;
|
||||
var volumesKey = new YamlScalarNode("volumes");
|
||||
YamlMappingNode volumes;
|
||||
var volumes = new YamlMappingNode();
|
||||
root.Children[volumesKey] = volumes;
|
||||
|
||||
if (root.Children.ContainsKey(volumesKey))
|
||||
volumes = (YamlMappingNode)root.Children[volumesKey];
|
||||
else
|
||||
// CIFS-backed named volumes
|
||||
var cifsVolumes = new[] { "cms-custom", "cms-backup", "cms-library", "cms-userscripts", "cms-ca-certs" };
|
||||
foreach (var vol in cifsVolumes)
|
||||
{
|
||||
volumes = new YamlMappingNode();
|
||||
root.Children[volumesKey] = volumes;
|
||||
var volName = $"{a}-{vol}";
|
||||
volumes.Children[new YamlScalarNode(volName)] = BuildCifsVolumeNode(vol, ctx);
|
||||
}
|
||||
|
||||
var prefix = AppConstants.SanitizeName(ctx.CustomerName);
|
||||
// Plain local volume for DB (not CIFS — stays on the node)
|
||||
volumes.Children[new YamlScalarNode($"{a}-db-data")] = new YamlMappingNode();
|
||||
}
|
||||
|
||||
// Named volumes (db, backup, custom)
|
||||
volumes.Children[new YamlScalarNode($"{prefix}_cms_db")] = new YamlMappingNode();
|
||||
volumes.Children[new YamlScalarNode($"{prefix}_cms_backup")] = new YamlMappingNode();
|
||||
volumes.Children[new YamlScalarNode($"{prefix}_cms_custom")] = new YamlMappingNode();
|
||||
/// <summary>
|
||||
/// Build a CIFS-driver volume node matching the pattern:
|
||||
/// <code>
|
||||
/// driver: local
|
||||
/// driver_opts:
|
||||
/// type: cifs
|
||||
/// device: "//fileserver.local/share/subpath"
|
||||
/// o: "addr=fileserver.local,credentials=/etc/docker-cifs-credentials,file_mode=0660,dir_mode=0770,vers=3.0"
|
||||
/// </code>
|
||||
/// The credentials= path points to a credentials file pre-deployed on the target host.
|
||||
/// </summary>
|
||||
private YamlMappingNode BuildCifsVolumeNode(string subPath, RenderContext ctx)
|
||||
{
|
||||
var node = new YamlMappingNode();
|
||||
node.Children[new YamlScalarNode("driver")] = new YamlScalarNode("local");
|
||||
|
||||
var opts = new YamlMappingNode();
|
||||
opts.Children[new YamlScalarNode("type")] = new YamlScalarNode("cifs");
|
||||
// Append the sub-path derived from the abbreviation to the base CIFS device
|
||||
var device = _cifsOptions.Device.TrimEnd('/') + "/" + ctx.CustomerAbbrev + "-" + subPath;
|
||||
opts.Children[new YamlScalarNode("device")] = new YamlScalarNode(device);
|
||||
// The credentials file path on the remote host — written ephemerally during provisioning
|
||||
var oValue = $"addr={_cifsOptions.ServerAddr},credentials={ctx.CifsCredentialsFilePath},{_cifsOptions.MountOptions}";
|
||||
opts.Children[new YamlScalarNode("o")] = new YamlScalarNode(oValue);
|
||||
|
||||
node.Children[new YamlScalarNode("driver_opts")] = opts;
|
||||
return node;
|
||||
}
|
||||
|
||||
private void EnsureSecrets(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var secretsKey = new YamlScalarNode("secrets");
|
||||
YamlMappingNode secrets;
|
||||
|
||||
if (root.Children.ContainsKey(secretsKey))
|
||||
secrets = (YamlMappingNode)root.Children[secretsKey];
|
||||
else
|
||||
{
|
||||
secrets = new YamlMappingNode();
|
||||
root.Children[secretsKey] = secrets;
|
||||
}
|
||||
var secrets = new YamlMappingNode();
|
||||
root.Children[secretsKey] = secrets;
|
||||
|
||||
foreach (var secretName in ctx.SecretNames)
|
||||
{
|
||||
var secretNode = new YamlMappingNode
|
||||
secrets.Children[new YamlScalarNode(secretName)] = new YamlMappingNode
|
||||
{
|
||||
{ "external", "true" }
|
||||
};
|
||||
secrets.Children[new YamlScalarNode(secretName)] = secretNode;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureNetworks(YamlMappingNode root, RenderContext ctx)
|
||||
{
|
||||
var networksKey = new YamlScalarNode("networks");
|
||||
var networks = new YamlMappingNode();
|
||||
root.Children[networksKey] = networks;
|
||||
|
||||
var netDef = new YamlMappingNode();
|
||||
netDef.Children[new YamlScalarNode("driver")] = new YamlScalarNode("overlay");
|
||||
netDef.Children[new YamlScalarNode("attachable")] = new YamlScalarNode("false");
|
||||
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = netDef;
|
||||
}
|
||||
|
||||
private static int GetServiceCount(YamlMappingNode root)
|
||||
{
|
||||
var servicesKey = new YamlScalarNode("services");
|
||||
@@ -342,22 +354,41 @@ public class ComposeRenderService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context object with all inputs needed to render a Compose file.
|
||||
/// All inputs needed to render a single Compose file.
|
||||
/// </summary>
|
||||
public class RenderContext
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
/// <summary>3-letter abbreviation used as naming prefix (e.g. "ots").</summary>
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
public string StackName { get; set; } = string.Empty;
|
||||
public string CmsServerName { get; set; } = string.Empty;
|
||||
public int HostHttpPort { get; set; }
|
||||
public string ThemeHostPath { get; set; } = string.Empty;
|
||||
public string LibraryHostPath { get; set; } = string.Empty;
|
||||
|
||||
// MySQL coordinates (external server — NOT a sidecar container)
|
||||
public string MySqlHost { get; set; } = string.Empty;
|
||||
public int MySqlPort { get; set; } = 3306;
|
||||
public string MySqlDatabase { get; set; } = string.Empty;
|
||||
public string MySqlUser { get; set; } = string.Empty;
|
||||
|
||||
// SMTP
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
public string SmtpUsername { get; set; } = string.Empty;
|
||||
public string TemplateYaml { get; set; } = string.Empty;
|
||||
public List<string> TemplateEnvLines { get; set; } = new();
|
||||
public List<string> Constraints { get; set; } = new();
|
||||
public string SmtpPassword { get; set; } = string.Empty;
|
||||
public string SmtpRewriteDomain { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Path on the remote target host where the CIFS credentials file will be placed ephemerally during provisioning.
|
||||
/// Written as the credentials= value in volume driver_opts.
|
||||
/// </summary>
|
||||
public string CifsCredentialsFilePath { get; set; } = "/etc/docker-cifs-credentials";
|
||||
|
||||
public string TemplateYaml { get; set; } = string.Empty;
|
||||
/// <summary>Parsed key/value pairs from template.env (placeholder-substituted).</summary>
|
||||
public Dictionary<string, string> TemplateEnvValues { get; set; } = new();
|
||||
|
||||
public List<string> Constraints { get; set; } = new();
|
||||
/// <summary>Secret names to declare as external in the compose file.</summary>
|
||||
public List<string> SecretNames { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps docker CLI commands for stack deploy/rm/ls.
|
||||
/// Requires docker binary on PATH and access to the Swarm manager (docker.sock).
|
||||
/// </summary>
|
||||
public class DockerCliService
|
||||
{
|
||||
private readonly DockerOptions _options;
|
||||
private readonly ILogger<DockerCliService> _logger;
|
||||
|
||||
public DockerCliService(IOptions<DockerOptions> options, ILogger<DockerCliService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy a stack using docker stack deploy --compose-file - (stdin).
|
||||
/// </summary>
|
||||
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var args = $"stack deploy --compose-file -";
|
||||
if (resolveImage)
|
||||
args += " --resolve-image changed";
|
||||
args += $" {stackName}";
|
||||
|
||||
_logger.LogInformation("Deploying stack: {StackName}", stackName);
|
||||
|
||||
var result = await RunDockerCommandAsync(args, composeYaml, _options.DeployTimeoutSeconds);
|
||||
sw.Stop();
|
||||
|
||||
result.StackName = stackName;
|
||||
result.DurationMs = sw.ElapsedMilliseconds;
|
||||
|
||||
if (result.Success)
|
||||
_logger.LogInformation("Stack deployed: {StackName} | exit={ExitCode} | duration={DurationMs}ms",
|
||||
stackName, result.ExitCode, result.DurationMs);
|
||||
else
|
||||
_logger.LogError("Stack deploy failed: {StackName} | exit={ExitCode} | error={Error}",
|
||||
stackName, result.ExitCode, result.ErrorMessage);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a stack.
|
||||
/// </summary>
|
||||
public async Task<DeploymentResultDto> RemoveStackAsync(string stackName)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
_logger.LogInformation("Removing stack: {StackName}", stackName);
|
||||
|
||||
var result = await RunDockerCommandAsync($"stack rm {stackName}", null, _options.DeployTimeoutSeconds);
|
||||
sw.Stop();
|
||||
|
||||
result.StackName = stackName;
|
||||
result.DurationMs = sw.ElapsedMilliseconds;
|
||||
|
||||
if (result.Success)
|
||||
_logger.LogInformation("Stack removed: {StackName} | duration={DurationMs}ms", stackName, result.DurationMs);
|
||||
else
|
||||
_logger.LogError("Stack remove failed: {StackName} | error={Error}", stackName, result.ErrorMessage);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all stacks.
|
||||
/// </summary>
|
||||
public async Task<List<StackInfo>> ListStacksAsync()
|
||||
{
|
||||
var result = await RunDockerCommandAsync("stack ls --format '{{.Name}}\\t{{.Services}}'", null, 10);
|
||||
if (!result.Success || string.IsNullOrWhiteSpace(result.Output))
|
||||
return new List<StackInfo>();
|
||||
|
||||
return result.Output
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 2);
|
||||
return new StackInfo
|
||||
{
|
||||
Name = parts[0].Trim(),
|
||||
ServiceCount = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var c) ? c : 0
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List services in a stack.
|
||||
/// </summary>
|
||||
public async Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName)
|
||||
{
|
||||
var result = await RunDockerCommandAsync(
|
||||
$"stack services {stackName} --format '{{{{.Name}}}}\\t{{{{.Image}}}}\\t{{{{.Replicas}}}}'",
|
||||
null, 10);
|
||||
|
||||
if (!result.Success || string.IsNullOrWhiteSpace(result.Output))
|
||||
return new List<ServiceInfo>();
|
||||
|
||||
return result.Output
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 3);
|
||||
return new ServiceInfo
|
||||
{
|
||||
Name = parts.Length > 0 ? parts[0].Trim() : "",
|
||||
Image = parts.Length > 1 ? parts[1].Trim() : "",
|
||||
Replicas = parts.Length > 2 ? parts[2].Trim() : ""
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<DeploymentResultDto> RunDockerCommandAsync(string arguments, string? stdin, int timeoutSeconds)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = arguments,
|
||||
RedirectStandardInput = stdin != null,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
// Pass DOCKER_HOST if using a non-default socket
|
||||
if (_options.SocketPath != "unix:///var/run/docker.sock")
|
||||
{
|
||||
psi.EnvironmentVariables["DOCKER_HOST"] = _options.SocketPath;
|
||||
}
|
||||
|
||||
var result = new DeploymentResultDto();
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("Failed to start docker process.");
|
||||
|
||||
if (stdin != null)
|
||||
{
|
||||
await process.StandardInput.WriteAsync(stdin);
|
||||
process.StandardInput.Close();
|
||||
}
|
||||
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
var completed = process.WaitForExit(timeoutSeconds * 1000);
|
||||
|
||||
if (!completed)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
result.Success = false;
|
||||
result.ErrorMessage = $"Docker command timed out after {timeoutSeconds}s.";
|
||||
result.Message = "Timeout";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Output = await stdoutTask;
|
||||
result.ErrorMessage = await stderrTask;
|
||||
result.ExitCode = process.ExitCode;
|
||||
result.Success = process.ExitCode == 0;
|
||||
result.Message = result.Success ? "Success" : "Failed";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Docker command execution failed: docker {Arguments}", arguments);
|
||||
result.Success = false;
|
||||
result.ErrorMessage = $"Failed to execute docker command: {ex.Message}";
|
||||
result.Message = "Error";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using System.Text;
|
||||
using Docker.DotNet;
|
||||
using Docker.DotNet.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using Secret = Docker.DotNet.Models.Secret;
|
||||
|
||||
namespace OTSSignsOrchestrator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages Docker Swarm secrets via Docker.DotNet.
|
||||
/// Creates, lists, and deletes secrets idempotently.
|
||||
/// NEVER logs secret values — only names and IDs.
|
||||
/// </summary>
|
||||
public class DockerSecretsService : IDisposable
|
||||
{
|
||||
private readonly DockerClient _client;
|
||||
private readonly ILogger<DockerSecretsService> _logger;
|
||||
|
||||
public DockerSecretsService(IOptions<DockerOptions> options, ILogger<DockerSecretsService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var socketPath = options.Value.SocketPath;
|
||||
if (socketPath.StartsWith("unix://"))
|
||||
{
|
||||
_client = new DockerClientConfiguration(new Uri(socketPath)).CreateClient();
|
||||
}
|
||||
else if (socketPath.StartsWith("tcp://") || socketPath.StartsWith("http://"))
|
||||
{
|
||||
_client = new DockerClientConfiguration(new Uri(socketPath)).CreateClient();
|
||||
}
|
||||
else
|
||||
{
|
||||
_client = new DockerClientConfiguration().CreateClient();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a secret if it doesn't exist. If it exists, optionally delete and recreate (rotate).
|
||||
/// Docker secrets are immutable — update requires delete + recreate.
|
||||
/// </summary>
|
||||
public async Task<(bool Created, string SecretId)> EnsureSecretAsync(string name, string value, bool rotate = false)
|
||||
{
|
||||
_logger.LogInformation("Ensuring secret exists: {SecretName}", name);
|
||||
|
||||
var existing = await FindSecretByNameAsync(name);
|
||||
|
||||
if (existing != null && !rotate)
|
||||
{
|
||||
_logger.LogDebug("Secret already exists: {SecretName} (id={SecretId})", name, existing.ID);
|
||||
return (false, existing.ID);
|
||||
}
|
||||
|
||||
if (existing != null && rotate)
|
||||
{
|
||||
_logger.LogInformation("Rotating secret: {SecretName} (old id={SecretId})", name, existing.ID);
|
||||
await _client.Secrets.DeleteAsync(existing.ID);
|
||||
}
|
||||
|
||||
var spec = new SecretSpec
|
||||
{
|
||||
Name = name,
|
||||
Data = Encoding.UTF8.GetBytes(value).ToList()
|
||||
};
|
||||
|
||||
var response = await _client.Secrets.CreateAsync(spec);
|
||||
_logger.LogInformation("Secret created: {SecretName} (id={SecretId})", name, response.ID);
|
||||
|
||||
return (true, response.ID);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all secrets (metadata only — names and IDs, never values).
|
||||
/// </summary>
|
||||
public async Task<List<SecretListItem>> ListSecretsAsync()
|
||||
{
|
||||
var secrets = await _client.Secrets.ListAsync();
|
||||
return secrets.Select(s => new SecretListItem
|
||||
{
|
||||
Id = s.ID,
|
||||
Name = s.Spec.Name,
|
||||
CreatedAt = s.CreatedAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a secret by name. Idempotent — returns success if not found.
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteSecretAsync(string name)
|
||||
{
|
||||
var existing = await FindSecretByNameAsync(name);
|
||||
if (existing == null)
|
||||
{
|
||||
_logger.LogDebug("Secret not found for deletion: {SecretName}", name);
|
||||
return true; // idempotent
|
||||
}
|
||||
|
||||
await _client.Secrets.DeleteAsync(existing.ID);
|
||||
_logger.LogInformation("Secret deleted: {SecretName} (id={SecretId})", name, existing.ID);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<Secret?> FindSecretByNameAsync(string name)
|
||||
{
|
||||
var secrets = await _client.Secrets.ListAsync();
|
||||
return secrets.FirstOrDefault(s =>
|
||||
string.Equals(s.Spec.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class SecretListItem
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace OTSSignsOrchestrator.Services;
|
||||
/// <summary>
|
||||
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
|
||||
/// Coordinates GitTemplateService, ComposeRenderService, ComposeValidationService,
|
||||
/// DockerCliService, DockerSecretsService, and XiboApiService.
|
||||
/// DockerCliService, DockerSecretsService, MySqlProvisionService, and XiboApiService.
|
||||
/// </summary>
|
||||
public class InstanceService
|
||||
{
|
||||
@@ -23,8 +23,12 @@ public class InstanceService
|
||||
private readonly ComposeValidationService _validation;
|
||||
private readonly DockerCliService _docker;
|
||||
private readonly DockerSecretsService _secrets;
|
||||
private readonly MySqlProvisionService _mysql;
|
||||
private readonly XiboApiService _xibo;
|
||||
private readonly DockerOptions _dockerOptions;
|
||||
private readonly MySqlAdminOptions _mysqlAdminOptions;
|
||||
private readonly CifsOptions _cifsOptions;
|
||||
private readonly InstanceDefaultsOptions _instanceDefaults;
|
||||
private readonly ILogger<InstanceService> _logger;
|
||||
|
||||
public InstanceService(
|
||||
@@ -34,8 +38,12 @@ public class InstanceService
|
||||
ComposeValidationService validation,
|
||||
DockerCliService docker,
|
||||
DockerSecretsService secrets,
|
||||
MySqlProvisionService mysql,
|
||||
XiboApiService xibo,
|
||||
IOptions<DockerOptions> dockerOptions,
|
||||
IOptions<MySqlAdminOptions> mysqlAdminOptions,
|
||||
IOptions<CifsOptions> cifsOptions,
|
||||
IOptions<InstanceDefaultsOptions> instanceDefaults,
|
||||
ILogger<InstanceService> logger)
|
||||
{
|
||||
_db = db;
|
||||
@@ -44,67 +52,129 @@ public class InstanceService
|
||||
_validation = validation;
|
||||
_docker = docker;
|
||||
_secrets = secrets;
|
||||
_mysql = mysql;
|
||||
_xibo = xibo;
|
||||
_dockerOptions = dockerOptions.Value;
|
||||
_mysqlAdminOptions = mysqlAdminOptions.Value;
|
||||
_cifsOptions = cifsOptions.Value;
|
||||
_instanceDefaults = instanceDefaults.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create and deploy a new CMS instance.
|
||||
/// Steps:
|
||||
/// 1. Validate abbreviation and uniqueness
|
||||
/// 2. Fetch templates from Git (configured repo)
|
||||
/// 3. Generate secrets in memory, create on Swarm — never persisted
|
||||
/// 4. Provision MySQL DB + user via direct connection
|
||||
/// 5. Render merged single-file Compose YAML
|
||||
/// 6. Validate Compose
|
||||
/// 7. Deploy stack
|
||||
/// 8. Persist instance metadata (no secret values stored)
|
||||
/// </summary>
|
||||
public async Task<DeploymentResultDto> CreateInstanceAsync(CreateInstanceDto dto, string? userId = null, string? ipAddress = null)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var opLog = StartOperation(OperationType.Create, userId, ipAddress);
|
||||
|
||||
// Resolve abbreviation to lowercase for naming, uppercase for display
|
||||
var abbrev = dto.CustomerAbbrev.ToLowerInvariant();
|
||||
|
||||
// Derive stack name and resource names from abbreviation
|
||||
var stackName = abbrev;
|
||||
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(abbrev);
|
||||
|
||||
// Resolve per-instance settings (use DTO override or fall back to global defaults)
|
||||
var templateRepoUrl = !string.IsNullOrWhiteSpace(dto.TemplateRepoUrl)
|
||||
? dto.TemplateRepoUrl
|
||||
: _instanceDefaults.TemplateRepoUrl;
|
||||
var templateRepoPat = dto.TemplateRepoPat ?? _instanceDefaults.TemplateRepoPat;
|
||||
|
||||
var cmsServerName = _instanceDefaults.CmsServerNameTemplate.Replace("{abbrev}", abbrev);
|
||||
var themeHostPath = !string.IsNullOrWhiteSpace(dto.ThemeHostPath)
|
||||
? dto.ThemeHostPath
|
||||
: _instanceDefaults.ThemeHostPath;
|
||||
var mysqlDatabase = _instanceDefaults.MySqlDatabaseTemplate.Replace("{abbrev}", abbrev);
|
||||
var mysqlUser = _instanceDefaults.MySqlUserTemplate.Replace("{abbrev}", abbrev);
|
||||
var smtpRewriteDomain = ExtractDomain(_instanceDefaults.SmtpUsername);
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Creating instance: stack={StackName}, customer={Customer}", dto.StackName, dto.CustomerName);
|
||||
// ----------------------------------------------------------------
|
||||
// Step 1 — Validate no duplicate stack/abbreviation
|
||||
// ----------------------------------------------------------------
|
||||
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
|
||||
|
||||
// 1. Validate no duplicate stack name
|
||||
var existing = await _db.CmsInstances.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.StackName == dto.StackName && i.DeletedAt == null);
|
||||
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
|
||||
if (existing != null)
|
||||
throw new InvalidOperationException($"Stack '{dto.StackName}' already exists.");
|
||||
throw new InvalidOperationException($"An instance with abbreviation '{dto.CustomerAbbrev}' (stack '{stackName}') already exists.");
|
||||
|
||||
// 2. Fetch templates from Git
|
||||
var template = await _git.FetchAsync(dto.TemplateRepoUrl, dto.TemplateRepoPat);
|
||||
// ----------------------------------------------------------------
|
||||
// Step 2 — Clone / fetch templates from configured Git repo
|
||||
// ----------------------------------------------------------------
|
||||
if (string.IsNullOrWhiteSpace(templateRepoUrl))
|
||||
throw new InvalidOperationException("No template repo URL configured. Set InstanceDefaults:TemplateRepoUrl in settings.");
|
||||
|
||||
// 3. Create Docker secrets
|
||||
var smtpResult = await _secrets.EnsureSecretAsync(AppConstants.GlobalSmtpSecretName, dto.SmtpPassword);
|
||||
var template = await _git.FetchAsync(templateRepoUrl, templateRepoPat);
|
||||
|
||||
// Parse template.env into key/value dictionary; apply abbreviation placeholder substitution
|
||||
var templateEnvValues = ParseEnvLines(template.EnvLines, abbrev);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Step 3 — Generate secrets in memory; create on Swarm via Docker
|
||||
// NEVER store generated password values in DB or disk
|
||||
// ----------------------------------------------------------------
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(dto.CustomerName);
|
||||
var mysqlResult = await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
|
||||
|
||||
// Track secrets in DB
|
||||
await EnsureSecretMetadata(AppConstants.GlobalSmtpSecretName, true, null);
|
||||
var mysqlSecretResult = await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
|
||||
_logger.LogInformation("MySQL secret '{SecretName}' — created={Created}", mysqlSecretName, mysqlSecretResult.Created);
|
||||
|
||||
// Track secret metadata in DB (name + customer, NOT the value)
|
||||
await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName);
|
||||
|
||||
// 4. Build render context
|
||||
var constraints = dto.Constraints ?? new List<string>();
|
||||
if (constraints.Count == 0)
|
||||
constraints = _dockerOptions.DefaultConstraints;
|
||||
// ----------------------------------------------------------------
|
||||
// Step 4 — Provision MySQL database and user
|
||||
// ----------------------------------------------------------------
|
||||
await _mysql.ProvisionAsync(mysqlDatabase, mysqlUser, mysqlPassword);
|
||||
// mysqlPassword goes out of scope after this method — it is not stored anywhere
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Step 5 — Build render context and render single Compose YAML
|
||||
// ----------------------------------------------------------------
|
||||
var constraints = (dto.Constraints is { Count: > 0 }) ? dto.Constraints : _dockerOptions.DefaultConstraints;
|
||||
|
||||
var hostHttpPort = _instanceDefaults.BaseHostHttpPort; // TODO: auto-increment per instance
|
||||
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
StackName = dto.StackName,
|
||||
CmsServerName = dto.CmsServerName,
|
||||
HostHttpPort = dto.HostHttpPort,
|
||||
ThemeHostPath = dto.ThemeHostPath,
|
||||
LibraryHostPath = dto.LibraryHostPath,
|
||||
SmtpServer = dto.SmtpServer,
|
||||
SmtpUsername = dto.SmtpUsername,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = hostHttpPort,
|
||||
ThemeHostPath = themeHostPath,
|
||||
MySqlHost = _mysqlAdminOptions.Host,
|
||||
MySqlPort = _mysqlAdminOptions.Port,
|
||||
MySqlDatabase = mysqlDatabase,
|
||||
MySqlUser = mysqlUser,
|
||||
SmtpServer = _instanceDefaults.SmtpServer,
|
||||
SmtpUsername = _instanceDefaults.SmtpUsername,
|
||||
SmtpPassword = _instanceDefaults.SmtpPassword,
|
||||
SmtpRewriteDomain = smtpRewriteDomain,
|
||||
TemplateYaml = template.Yaml,
|
||||
TemplateEnvLines = template.EnvLines,
|
||||
TemplateEnvValues = templateEnvValues,
|
||||
Constraints = constraints,
|
||||
SecretNames = new List<string> { AppConstants.GlobalSmtpSecretName, mysqlSecretName }
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
CifsCredentialsFilePath = "/etc/docker-cifs-credentials"
|
||||
};
|
||||
|
||||
// 5. Render Compose YAML
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
|
||||
// 6. Validate Compose
|
||||
// ----------------------------------------------------------------
|
||||
// Step 6 — Validate Compose
|
||||
// ----------------------------------------------------------------
|
||||
if (_dockerOptions.ValidateBeforeDeploy)
|
||||
{
|
||||
var validationResult = _validation.Validate(composeYaml);
|
||||
@@ -115,25 +185,30 @@ public class InstanceService
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Deploy stack
|
||||
var deployResult = await _docker.DeployStackAsync(dto.StackName, composeYaml);
|
||||
// ----------------------------------------------------------------
|
||||
// Step 7 — Deploy stack via Docker
|
||||
// ----------------------------------------------------------------
|
||||
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
||||
|
||||
// 8. Store instance in DB
|
||||
// ----------------------------------------------------------------
|
||||
// Step 8 — Persist instance metadata (no secret values stored)
|
||||
// ----------------------------------------------------------------
|
||||
var instance = new CmsInstance
|
||||
{
|
||||
CustomerName = dto.CustomerName,
|
||||
StackName = dto.StackName,
|
||||
CmsServerName = dto.CmsServerName,
|
||||
HostHttpPort = dto.HostHttpPort,
|
||||
ThemeHostPath = dto.ThemeHostPath,
|
||||
LibraryHostPath = dto.LibraryHostPath,
|
||||
SmtpServer = dto.SmtpServer,
|
||||
SmtpUsername = dto.SmtpUsername,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = hostHttpPort,
|
||||
ThemeHostPath = themeHostPath,
|
||||
LibraryHostPath = _instanceDefaults.LibraryShareSubPath.Replace("{abbrev}", abbrev),
|
||||
SmtpServer = _instanceDefaults.SmtpServer,
|
||||
SmtpUsername = _instanceDefaults.SmtpUsername,
|
||||
Constraints = JsonSerializer.Serialize(constraints),
|
||||
TemplateRepoUrl = dto.TemplateRepoUrl,
|
||||
TemplateRepoPat = dto.TemplateRepoPat,
|
||||
TemplateRepoUrl = templateRepoUrl,
|
||||
TemplateRepoPat = templateRepoPat,
|
||||
TemplateLastFetch = template.FetchedAt,
|
||||
Status = InstanceStatus.Active,
|
||||
XiboUsername = dto.XiboUsername,
|
||||
@@ -146,14 +221,14 @@ public class InstanceService
|
||||
sw.Stop();
|
||||
opLog.InstanceId = instance.Id;
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance deployed: {dto.StackName}";
|
||||
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",
|
||||
dto.StackName, instance.Id, sw.ElapsedMilliseconds);
|
||||
stackName, instance.Id, sw.ElapsedMilliseconds);
|
||||
|
||||
deployResult.ServiceCount = 4;
|
||||
deployResult.Message = "Instance deployed successfully.";
|
||||
@@ -168,7 +243,7 @@ public class InstanceService
|
||||
_db.OperationLogs.Add(opLog);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogError(ex, "Instance create failed: {StackName}", dto.StackName);
|
||||
_logger.LogError(ex, "Instance create failed: abbrev={Abbrev}", abbrev);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -208,20 +283,31 @@ public class InstanceService
|
||||
|
||||
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(instance.CustomerName);
|
||||
|
||||
var abbrev = instance.CustomerAbbrev;
|
||||
var templateEnvValues = ParseEnvLines(template.EnvLines, abbrev);
|
||||
var smtpRewriteDomain = ExtractDomain(instance.SmtpUsername);
|
||||
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = instance.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = instance.StackName,
|
||||
CmsServerName = instance.CmsServerName,
|
||||
HostHttpPort = instance.HostHttpPort,
|
||||
ThemeHostPath = instance.ThemeHostPath,
|
||||
LibraryHostPath = instance.LibraryHostPath,
|
||||
MySqlHost = _mysqlAdminOptions.Host,
|
||||
MySqlPort = _mysqlAdminOptions.Port,
|
||||
MySqlDatabase = _instanceDefaults.MySqlDatabaseTemplate.Replace("{abbrev}", abbrev),
|
||||
MySqlUser = _instanceDefaults.MySqlUserTemplate.Replace("{abbrev}", abbrev),
|
||||
SmtpServer = instance.SmtpServer,
|
||||
SmtpUsername = instance.SmtpUsername,
|
||||
SmtpPassword = _instanceDefaults.SmtpPassword,
|
||||
SmtpRewriteDomain = smtpRewriteDomain,
|
||||
TemplateYaml = template.Yaml,
|
||||
TemplateEnvLines = template.EnvLines,
|
||||
TemplateEnvValues = templateEnvValues,
|
||||
Constraints = constraints,
|
||||
SecretNames = new List<string> { AppConstants.GlobalSmtpSecretName, mysqlSecretName }
|
||||
SecretNames = new List<string> { mysqlSecretName },
|
||||
CifsCredentialsFilePath = "/etc/docker-cifs-credentials"
|
||||
};
|
||||
|
||||
var composeYaml = _compose.Render(renderCtx);
|
||||
@@ -430,4 +516,36 @@ public class InstanceService
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||
return RandomNumberGenerator.GetString(chars, length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse template.env lines into a key/value dictionary.
|
||||
/// Replaces {abbrev} placeholders with the customer abbreviation.
|
||||
/// Skips empty lines and comments.
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> ParseEnvLines(IEnumerable<string> lines, string abbrev)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
|
||||
continue;
|
||||
var eqIdx = trimmed.IndexOf('=');
|
||||
if (eqIdx <= 0) continue;
|
||||
var key = trimmed[..eqIdx].Trim();
|
||||
var value = trimmed[(eqIdx + 1)..].Trim()
|
||||
.Replace("{CUSTOMERABBREVIATION}", abbrev)
|
||||
.Replace("{CUSTOMERABBREV}", abbrev)
|
||||
.Replace("{abbrev}", abbrev);
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Extracts the domain part from an email address, e.g. "user@ots-signs.com" → "ots-signs.com".</summary>
|
||||
private static string ExtractDomain(string email)
|
||||
{
|
||||
var atIdx = email?.IndexOf('@') ?? -1;
|
||||
return atIdx >= 0 ? email![(atIdx + 1)..] : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages OIDC provider CRUD, test connections, and primary provider logic.
|
||||
/// </summary>
|
||||
public class OidcProviderService
|
||||
{
|
||||
private readonly XiboContext _db;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<OidcProviderService> _logger;
|
||||
|
||||
public OidcProviderService(
|
||||
XiboContext db,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<OidcProviderService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<OidcProvider>> GetActiveProvidersAsync()
|
||||
{
|
||||
return await _db.OidcProviders
|
||||
.Where(p => p.IsEnabled)
|
||||
.OrderByDescending(p => p.IsPrimary)
|
||||
.ThenBy(p => p.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<OidcProvider>> GetAllProvidersAsync()
|
||||
{
|
||||
return await _db.OidcProviders
|
||||
.OrderByDescending(p => p.IsPrimary)
|
||||
.ThenBy(p => p.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<OidcProvider?> GetProviderAsync(Guid id)
|
||||
{
|
||||
return await _db.OidcProviders.FindAsync(id);
|
||||
}
|
||||
|
||||
public async Task<OidcProvider?> GetPrimaryProviderAsync()
|
||||
{
|
||||
return await _db.OidcProviders
|
||||
.Where(p => p.IsEnabled && p.IsPrimary)
|
||||
.FirstOrDefaultAsync()
|
||||
?? await _db.OidcProviders
|
||||
.Where(p => p.IsEnabled)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<OidcProvider> CreateProviderAsync(CreateOidcProviderDto dto)
|
||||
{
|
||||
_logger.LogInformation("Creating OIDC provider: {Name}", dto.Name);
|
||||
|
||||
var existing = await _db.OidcProviders.FirstOrDefaultAsync(p => p.Name == dto.Name);
|
||||
if (existing != null)
|
||||
throw new InvalidOperationException($"OIDC provider with name '{dto.Name}' already exists.");
|
||||
|
||||
var provider = new OidcProvider
|
||||
{
|
||||
Name = dto.Name,
|
||||
Authority = dto.Authority.TrimEnd('/'),
|
||||
ClientId = dto.ClientId,
|
||||
ClientSecret = dto.ClientSecret,
|
||||
Audience = dto.Audience,
|
||||
IsEnabled = dto.IsEnabled,
|
||||
IsPrimary = dto.IsPrimary
|
||||
};
|
||||
|
||||
// If setting as primary, clear existing primary
|
||||
if (dto.IsPrimary)
|
||||
await ClearPrimaryAsync();
|
||||
|
||||
_db.OidcProviders.Add(provider);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("OIDC provider created: {Name} (id={Id})", provider.Name, provider.Id);
|
||||
return provider;
|
||||
}
|
||||
|
||||
public async Task<OidcProvider> UpdateProviderAsync(Guid id, UpdateOidcProviderDto dto)
|
||||
{
|
||||
var provider = await _db.OidcProviders.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"OIDC provider {id} not found.");
|
||||
|
||||
_logger.LogInformation("Updating OIDC provider: {Name} (id={Id})", provider.Name, id);
|
||||
|
||||
if (dto.Name != null) provider.Name = dto.Name;
|
||||
if (dto.Authority != null) provider.Authority = dto.Authority.TrimEnd('/');
|
||||
if (dto.ClientId != null) provider.ClientId = dto.ClientId;
|
||||
if (dto.ClientSecret != null) provider.ClientSecret = dto.ClientSecret;
|
||||
if (dto.Audience != null) provider.Audience = dto.Audience;
|
||||
if (dto.IsEnabled.HasValue) provider.IsEnabled = dto.IsEnabled.Value;
|
||||
|
||||
if (dto.IsPrimary == true)
|
||||
{
|
||||
await ClearPrimaryAsync();
|
||||
provider.IsPrimary = true;
|
||||
}
|
||||
else if (dto.IsPrimary == false)
|
||||
{
|
||||
provider.IsPrimary = false;
|
||||
}
|
||||
|
||||
provider.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("OIDC provider updated: {Name} (id={Id})", provider.Name, id);
|
||||
return provider;
|
||||
}
|
||||
|
||||
public async Task DeleteProviderAsync(Guid id)
|
||||
{
|
||||
var provider = await _db.OidcProviders.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"OIDC provider {id} not found.");
|
||||
|
||||
_logger.LogInformation("Deleting OIDC provider: {Name} (id={Id})", provider.Name, id);
|
||||
|
||||
_db.OidcProviders.Remove(provider);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("OIDC provider deleted: {Name}", provider.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that an OIDC provider's discovery endpoint is reachable.
|
||||
/// </summary>
|
||||
public async Task<(bool IsValid, string Message)> TestConnectionAsync(OidcProvider provider)
|
||||
{
|
||||
_logger.LogInformation("Testing OIDC provider: {Name} ({Authority})", provider.Name, provider.Authority);
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var discoveryUrl = $"{provider.Authority.TrimEnd('/')}/.well-known/openid-configuration";
|
||||
var response = await client.GetAsync(discoveryUrl);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
if (content.Contains("issuer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("OIDC provider test succeeded: {Name}", provider.Name);
|
||||
return (true, "Connection successful. Discovery document found.");
|
||||
}
|
||||
|
||||
return (false, "Endpoint reachable but response doesn't look like an OIDC discovery document.");
|
||||
}
|
||||
|
||||
return (false, $"OIDC discovery endpoint returned HTTP {(int)response.StatusCode}.");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return (false, "Connection timed out. Check the Authority URL.");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return (false, $"Cannot reach OIDC provider: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearPrimaryAsync()
|
||||
{
|
||||
var primaries = await _db.OidcProviders.Where(p => p.IsPrimary).ToListAsync();
|
||||
foreach (var p in primaries)
|
||||
p.IsPrimary = false;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user