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>
|
/// <summary>
|
||||||
/// Shared constants for the application.
|
/// Shared constants for the application.
|
||||||
@@ -8,10 +8,6 @@ public static class AppConstants
|
|||||||
public const string AdminRole = "Admin";
|
public const string AdminRole = "Admin";
|
||||||
public const string ViewerRole = "Viewer";
|
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>
|
/// <summary>Docker secret name for the global SMTP password.</summary>
|
||||||
public const string GlobalSmtpSecretName = "global_smtp_password";
|
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.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
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
|
public class XiboContext : DbContext
|
||||||
{
|
{
|
||||||
@@ -16,9 +16,10 @@ public class XiboContext : DbContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DbSet<CmsInstance> CmsInstances => Set<CmsInstance>();
|
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<SecretMetadata> SecretMetadata => Set<SecretMetadata>();
|
||||||
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
||||||
|
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -29,11 +30,13 @@ public class XiboContext : DbContext
|
|||||||
{
|
{
|
||||||
entity.HasIndex(e => e.StackName).IsUnique();
|
entity.HasIndex(e => e.StackName).IsUnique();
|
||||||
entity.HasIndex(e => e.CustomerName);
|
entity.HasIndex(e => e.CustomerName);
|
||||||
|
|
||||||
// Query filter for soft deletes
|
|
||||||
entity.HasQueryFilter(e => e.DeletedAt == null);
|
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)
|
if (_dataProtection != null)
|
||||||
{
|
{
|
||||||
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.CmsInstance");
|
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.XiboPassword).HasConversion(pwdConverter);
|
||||||
entity.Property(e => e.XiboUsername).HasConversion(pwdConverter);
|
entity.Property(e => e.XiboUsername).HasConversion(pwdConverter);
|
||||||
entity.Property(e => e.TemplateRepoPat).HasConversion(pwdConverter);
|
entity.Property(e => e.TemplateRepoPat).HasConversion(pwdConverter);
|
||||||
|
entity.Property(e => e.CifsPassword).HasConversion(pwdConverter);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- OidcProvider ---
|
// --- SshHost ---
|
||||||
modelBuilder.Entity<OidcProvider>(entity =>
|
modelBuilder.Entity<SshHost>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasIndex(e => e.Name).IsUnique();
|
entity.HasIndex(e => e.Label).IsUnique();
|
||||||
|
|
||||||
if (_dataProtection != null)
|
if (_dataProtection != null)
|
||||||
{
|
{
|
||||||
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.OidcProvider");
|
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.SshHost");
|
||||||
var secretConverter = new ValueConverter<string, string>(
|
var passphraseConverter = new ValueConverter<string?, string?>(
|
||||||
v => protector.Protect(v),
|
v => v != null ? protector.Protect(v) : null,
|
||||||
v => protector.Unprotect(v));
|
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.InstanceId);
|
||||||
entity.HasIndex(e => e.Operation);
|
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.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using OTSSignsOrchestrator.Data;
|
using OTSSignsOrchestrator.Core.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Migrations
|
namespace OTSSignsOrchestrator.Core.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(XiboContext))]
|
[DbContext(typeof(XiboContext))]
|
||||||
[Migration("20260212185423_InitialCreate")]
|
[Migration("20260217004115_DesktopInitial")]
|
||||||
partial class InitialCreate
|
partial class DesktopInitial
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -20,7 +20,7 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -64,6 +64,9 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SshHostId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("StackName")
|
b.Property<string>("StackName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@@ -114,63 +117,15 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CustomerName");
|
b.HasIndex("CustomerName");
|
||||||
|
|
||||||
|
b.HasIndex("SshHostId");
|
||||||
|
|
||||||
b.HasIndex("StackName")
|
b.HasIndex("StackName")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("CmsInstances");
|
b.ToTable("CmsInstances");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OidcProvider", b =>
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", 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 =>
|
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -182,10 +137,6 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
b.Property<Guid?>("InstanceId")
|
b.Property<Guid?>("InstanceId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("IpAddress")
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("Message")
|
b.Property<string>("Message")
|
||||||
.HasMaxLength(2000)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -193,9 +144,6 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
b.Property<int>("Operation")
|
b.Property<int>("Operation")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<Guid?>("ProviderId")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<int>("Status")
|
b.Property<int>("Status")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -212,14 +160,12 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
|
|
||||||
b.HasIndex("Operation");
|
b.HasIndex("Operation");
|
||||||
|
|
||||||
b.HasIndex("ProviderId");
|
|
||||||
|
|
||||||
b.HasIndex("Timestamp");
|
b.HasIndex("Timestamp");
|
||||||
|
|
||||||
b.ToTable("OperationLogs");
|
b.ToTable("OperationLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.SecretMetadata", b =>
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -251,25 +197,93 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
b.ToTable("SecretMetadata");
|
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")
|
.WithMany("OperationLogs")
|
||||||
.HasForeignKey("InstanceId");
|
.HasForeignKey("InstanceId");
|
||||||
|
|
||||||
b.HasOne("XiboSwarmAdmin.Models.Entities.OidcProvider", "Provider")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ProviderId");
|
|
||||||
|
|
||||||
b.Navigation("Instance");
|
b.Navigation("Instance");
|
||||||
|
|
||||||
b.Navigation("Provider");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("OperationLogs");
|
b.Navigation("OperationLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Instances");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,14 +3,53 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Migrations
|
namespace OTSSignsOrchestrator.Core.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class InitialCreate : Migration
|
public partial class DesktopInitial : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
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(
|
migrationBuilder.CreateTable(
|
||||||
name: "CmsInstances",
|
name: "CmsInstances",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@@ -36,47 +75,18 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
XiboApiTestedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
XiboApiTestedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
UpdatedAt = 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 =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_CmsInstances", x => x.Id);
|
table.PrimaryKey("PK_CmsInstances", x => x.Id);
|
||||||
});
|
table.ForeignKey(
|
||||||
|
name: "FK_CmsInstances_SshHosts_SshHostId",
|
||||||
migrationBuilder.CreateTable(
|
column: x => x.SshHostId,
|
||||||
name: "OidcProviders",
|
principalTable: "SshHosts",
|
||||||
columns: table => new
|
principalColumn: "Id",
|
||||||
{
|
onDelete: ReferentialAction.SetNull);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
@@ -86,13 +96,11 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
Operation = table.Column<int>(type: "INTEGER", nullable: false),
|
Operation = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
InstanceId = table.Column<Guid>(type: "TEXT", nullable: true),
|
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),
|
UserId = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
|
||||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
Message = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
|
Message = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
|
||||||
DurationMs = table.Column<long>(type: "INTEGER", nullable: true),
|
DurationMs = table.Column<long>(type: "INTEGER", nullable: true),
|
||||||
Timestamp = table.Column<DateTime>(type: "TEXT", nullable: false),
|
Timestamp = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
IpAddress = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true)
|
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
@@ -102,11 +110,6 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
column: x => x.InstanceId,
|
column: x => x.InstanceId,
|
||||||
principalTable: "CmsInstances",
|
principalTable: "CmsInstances",
|
||||||
principalColumn: "Id");
|
principalColumn: "Id");
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_OperationLogs_OidcProviders_ProviderId",
|
|
||||||
column: x => x.ProviderId,
|
|
||||||
principalTable: "OidcProviders",
|
|
||||||
principalColumn: "Id");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
@@ -114,18 +117,17 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
table: "CmsInstances",
|
table: "CmsInstances",
|
||||||
column: "CustomerName");
|
column: "CustomerName");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CmsInstances_SshHostId",
|
||||||
|
table: "CmsInstances",
|
||||||
|
column: "SshHostId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_CmsInstances_StackName",
|
name: "IX_CmsInstances_StackName",
|
||||||
table: "CmsInstances",
|
table: "CmsInstances",
|
||||||
column: "StackName",
|
column: "StackName",
|
||||||
unique: true);
|
unique: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_OidcProviders_Name",
|
|
||||||
table: "OidcProviders",
|
|
||||||
column: "Name",
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_OperationLogs_InstanceId",
|
name: "IX_OperationLogs_InstanceId",
|
||||||
table: "OperationLogs",
|
table: "OperationLogs",
|
||||||
@@ -136,11 +138,6 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
table: "OperationLogs",
|
table: "OperationLogs",
|
||||||
column: "Operation");
|
column: "Operation");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_OperationLogs_ProviderId",
|
|
||||||
table: "OperationLogs",
|
|
||||||
column: "ProviderId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_OperationLogs_Timestamp",
|
name: "IX_OperationLogs_Timestamp",
|
||||||
table: "OperationLogs",
|
table: "OperationLogs",
|
||||||
@@ -151,6 +148,12 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
table: "SecretMetadata",
|
table: "SecretMetadata",
|
||||||
column: "Name",
|
column: "Name",
|
||||||
unique: true);
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SshHosts_Label",
|
||||||
|
table: "SshHosts",
|
||||||
|
column: "Label",
|
||||||
|
unique: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -166,7 +169,7 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
name: "CmsInstances");
|
name: "CmsInstances");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "OidcProviders");
|
name: "SshHosts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,22 +2,25 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using OTSSignsOrchestrator.Data;
|
using OTSSignsOrchestrator.Core.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Migrations
|
namespace OTSSignsOrchestrator.Core.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(XiboContext))]
|
[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
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -35,6 +38,11 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerAbbrev")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(3)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("CustomerName")
|
b.Property<string>("CustomerName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@@ -61,6 +69,9 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SshHostId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("StackName")
|
b.Property<string>("StackName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@@ -111,63 +122,15 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CustomerName");
|
b.HasIndex("CustomerName");
|
||||||
|
|
||||||
|
b.HasIndex("SshHostId");
|
||||||
|
|
||||||
b.HasIndex("StackName")
|
b.HasIndex("StackName")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("CmsInstances");
|
b.ToTable("CmsInstances");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OidcProvider", b =>
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", 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 =>
|
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -179,10 +142,6 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
b.Property<Guid?>("InstanceId")
|
b.Property<Guid?>("InstanceId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("IpAddress")
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("Message")
|
b.Property<string>("Message")
|
||||||
.HasMaxLength(2000)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -190,9 +149,6 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
b.Property<int>("Operation")
|
b.Property<int>("Operation")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<Guid?>("ProviderId")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<int>("Status")
|
b.Property<int>("Status")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -209,14 +165,12 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
|
|
||||||
b.HasIndex("Operation");
|
b.HasIndex("Operation");
|
||||||
|
|
||||||
b.HasIndex("ProviderId");
|
|
||||||
|
|
||||||
b.HasIndex("Timestamp");
|
b.HasIndex("Timestamp");
|
||||||
|
|
||||||
b.ToTable("OperationLogs");
|
b.ToTable("OperationLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.SecretMetadata", b =>
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -248,25 +202,93 @@ namespace OTSSignsOrchestrator.Migrations
|
|||||||
b.ToTable("SecretMetadata");
|
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")
|
.WithMany("OperationLogs")
|
||||||
.HasForeignKey("InstanceId");
|
.HasForeignKey("InstanceId");
|
||||||
|
|
||||||
b.HasOne("XiboSwarmAdmin.Models.Entities.OidcProvider", "Provider")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ProviderId");
|
|
||||||
|
|
||||||
b.Navigation("Instance");
|
b.Navigation("Instance");
|
||||||
|
|
||||||
b.Navigation("Provider");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("OperationLogs");
|
b.Navigation("OperationLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Instances");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#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
|
public class DeploymentResultDto
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace OTSSignsOrchestrator.Models.DTOs;
|
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
|
||||||
public class TemplateConfig
|
public class TemplateConfig
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Models.DTOs;
|
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
|
||||||
public class UpdateInstanceDto
|
public class UpdateInstanceDto
|
||||||
{
|
{
|
||||||
@@ -23,4 +23,21 @@ public class UpdateInstanceDto
|
|||||||
|
|
||||||
[MaxLength(200)]
|
[MaxLength(200)]
|
||||||
public string? XiboPassword { get; set; }
|
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;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Models.Entities;
|
namespace OTSSignsOrchestrator.Core.Models.Entities;
|
||||||
|
|
||||||
public enum OperationType
|
public enum OperationType
|
||||||
{
|
{
|
||||||
@@ -33,26 +33,16 @@ public class OperationLog
|
|||||||
[ForeignKey(nameof(InstanceId))]
|
[ForeignKey(nameof(InstanceId))]
|
||||||
public CmsInstance? Instance { get; set; }
|
public CmsInstance? Instance { get; set; }
|
||||||
|
|
||||||
public Guid? ProviderId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(ProviderId))]
|
|
||||||
public OidcProvider? Provider { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(200)]
|
[MaxLength(200)]
|
||||||
public string? UserId { get; set; }
|
public string? UserId { get; set; }
|
||||||
|
|
||||||
public OperationStatus Status { get; set; } = OperationStatus.Pending;
|
public OperationStatus Status { get; set; } = OperationStatus.Pending;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Human-readable message. NEVER includes secret values.</summary>
|
||||||
/// Human-readable message. NEVER includes secret values.
|
|
||||||
/// </summary>
|
|
||||||
[MaxLength(2000)]
|
[MaxLength(2000)]
|
||||||
public string? Message { get; set; }
|
public string? Message { get; set; }
|
||||||
|
|
||||||
public long? DurationMs { get; set; }
|
public long? DurationMs { get; set; }
|
||||||
|
|
||||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
[MaxLength(50)]
|
|
||||||
public string? IpAddress { get; set; }
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Models.Entities;
|
namespace OTSSignsOrchestrator.Core.Models.Entities;
|
||||||
|
|
||||||
public class SecretMetadata
|
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>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
@@ -7,16 +7,18 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Docker.DotNet" Version="3.125.15" />
|
|
||||||
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
|
<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">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<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.Console" Version="6.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.3.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;
|
using YamlDotNet.RepresentationModel;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Services;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a rendered Compose YAML before deployment.
|
/// Validates a rendered Compose YAML before deployment.
|
||||||
/// Checks syntax, required structure, secrets references, and service presence.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ComposeValidationService
|
public class ComposeValidationService
|
||||||
{
|
{
|
||||||
private readonly ILogger<ComposeValidationService> _logger;
|
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)
|
public ComposeValidationService(ILogger<ComposeValidationService> logger)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public ValidationResult Validate(string composeYaml, string? customerAbbrev = null)
|
||||||
/// Validate a Compose YAML string; return errors (empty list = valid).
|
|
||||||
/// </summary>
|
|
||||||
public ValidationResult Validate(string composeYaml)
|
|
||||||
{
|
{
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
var warnings = new List<string>();
|
var warnings = new List<string>();
|
||||||
|
|
||||||
// 1. YAML syntax
|
|
||||||
YamlStream yamlStream;
|
YamlStream yamlStream;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -55,13 +46,11 @@ public class ComposeValidationService
|
|||||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Required top-level keys
|
|
||||||
if (!HasKey(root, "services"))
|
if (!HasKey(root, "services"))
|
||||||
errors.Add("Missing required top-level key: 'services'.");
|
errors.Add("Missing required top-level key: 'services'.");
|
||||||
if (!HasKey(root, "secrets"))
|
if (!HasKey(root, "secrets"))
|
||||||
warnings.Add("Missing top-level key: 'secrets'. Secrets may not be available.");
|
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)
|
if (HasKey(root, "services") && root.Children[new YamlScalarNode("services")] is YamlMappingNode services)
|
||||||
{
|
{
|
||||||
var presentServices = services.Children.Keys
|
var presentServices = services.Children.Keys
|
||||||
@@ -69,17 +58,27 @@ public class ComposeValidationService
|
|||||||
.Select(k => k.Value!)
|
.Select(k => k.Value!)
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.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))
|
foreach (var suffix in requiredSuffixes)
|
||||||
errors.Add($"Missing required service: '{required}'.");
|
{
|
||||||
|
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)
|
foreach (var (key, value) in services.Children)
|
||||||
{
|
{
|
||||||
if (key is YamlScalarNode keyNode && value is YamlMappingNode svcNode)
|
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 secretsNode)
|
||||||
if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secrets)
|
|
||||||
{
|
{
|
||||||
foreach (var (key, value) in secrets.Children)
|
foreach (var (key, value) in secretsNode.Children)
|
||||||
{
|
{
|
||||||
if (value is YamlMappingNode secretNode)
|
if (value is YamlMappingNode secretNode)
|
||||||
{
|
{
|
||||||
if (!HasKey(secretNode, "external"))
|
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 };
|
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasKey(YamlMappingNode node, string key)
|
private static bool HasKey(YamlMappingNode node, string key)
|
||||||
{
|
=> node.Children.ContainsKey(new YamlScalarNode(key));
|
||||||
return node.Children.ContainsKey(new YamlScalarNode(key));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ValidationResult
|
public class ValidationResult
|
||||||
{
|
{
|
||||||
public bool IsValid => Errors.Count == 0;
|
|
||||||
public List<string> Errors { get; set; } = new();
|
public List<string> Errors { get; set; } = new();
|
||||||
public List<string> Warnings { 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.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using LibGit2Sharp;
|
using LibGit2Sharp;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using OTSSignsOrchestrator.Configuration;
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
using OTSSignsOrchestrator.Models.DTOs;
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Services;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches template.yml and template.env from a Git repository using LibGit2Sharp.
|
/// Fetches template.yml and template.env from a Git repository using LibGit2Sharp.
|
||||||
@@ -22,10 +23,6 @@ public class GitTemplateService
|
|||||||
_logger = logger;
|
_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)
|
public async Task<TemplateConfig> FetchAsync(string repoUrl, string? pat = null, bool forceRefresh = false)
|
||||||
{
|
{
|
||||||
var cacheKey = ComputeCacheKey(repoUrl);
|
var cacheKey = ComputeCacheKey(repoUrl);
|
||||||
@@ -79,20 +76,12 @@ public class GitTemplateService
|
|||||||
|
|
||||||
Directory.CreateDirectory(cacheDir);
|
Directory.CreateDirectory(cacheDir);
|
||||||
|
|
||||||
var cloneOpts = new CloneOptions
|
var cloneOpts = new CloneOptions { IsBare = false, RecurseSubmodules = false };
|
||||||
{
|
|
||||||
IsBare = false,
|
|
||||||
RecurseSubmodules = false
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(pat))
|
if (!string.IsNullOrEmpty(pat))
|
||||||
{
|
{
|
||||||
cloneOpts.FetchOptions.CredentialsProvider = (_, _, _) =>
|
cloneOpts.FetchOptions.CredentialsProvider = (_, _, _) =>
|
||||||
new UsernamePasswordCredentials
|
new UsernamePasswordCredentials { Username = pat, Password = string.Empty };
|
||||||
{
|
|
||||||
Username = pat,
|
|
||||||
Password = string.Empty
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -118,25 +107,19 @@ public class GitTemplateService
|
|||||||
if (!string.IsNullOrEmpty(pat))
|
if (!string.IsNullOrEmpty(pat))
|
||||||
{
|
{
|
||||||
fetchOpts.CredentialsProvider = (_, _, _) =>
|
fetchOpts.CredentialsProvider = (_, _, _) =>
|
||||||
new UsernamePasswordCredentials
|
new UsernamePasswordCredentials { Username = pat, Password = string.Empty };
|
||||||
{
|
|
||||||
Username = pat,
|
|
||||||
Password = string.Empty
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var remote = repo.Network.Remotes["origin"];
|
var remote = repo.Network.Remotes["origin"];
|
||||||
var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification).ToArray();
|
var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification).ToArray();
|
||||||
Commands.Fetch(repo, "origin", refSpecs, fetchOpts, "Auto-fetch for template update");
|
Commands.Fetch(repo, "origin", refSpecs, fetchOpts, "Auto-fetch for template update");
|
||||||
|
|
||||||
// Fast-forward the default branch
|
|
||||||
var trackingBranch = repo.Head.TrackedBranch;
|
var trackingBranch = repo.Head.TrackedBranch;
|
||||||
if (trackingBranch != null)
|
if (trackingBranch != null)
|
||||||
{
|
{
|
||||||
repo.Reset(ResetMode.Hard, trackingBranch.Tip);
|
repo.Reset(ResetMode.Hard, trackingBranch.Tip);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cache timestamp
|
|
||||||
WriteCacheTimestamp(cacheDir);
|
WriteCacheTimestamp(cacheDir);
|
||||||
}
|
}
|
||||||
catch (LibGit2SharpException ex)
|
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 Microsoft.Extensions.Logging;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using OTSSignsOrchestrator.Configuration;
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Services;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Communicates with deployed Xibo CMS instances via REST API.
|
/// Tests connectivity to deployed Xibo CMS instances using OAuth2.
|
||||||
/// Tests connectivity and provides stubs for future management operations.
|
|
||||||
/// NEVER logs passwords or credentials.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class XiboApiService
|
public class XiboApiService
|
||||||
{
|
{
|
||||||
@@ -27,10 +23,6 @@ public class XiboApiService
|
|||||||
_logger = logger;
|
_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)
|
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string username, string password)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
||||||
@@ -40,7 +32,6 @@ public class XiboApiService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Xibo CMS uses OAuth2. Try resource-owner password grant first.
|
|
||||||
var baseUrl = instanceUrl.TrimEnd('/');
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
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}",
|
_logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
|
||||||
instanceUrl, (int)response.StatusCode);
|
instanceUrl, (int)response.StatusCode);
|
||||||
|
|
||||||
@@ -83,45 +73,13 @@ public class XiboApiService
|
|||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
catch (TaskCanceledException)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Xibo connection test timed out: {InstanceUrl}", instanceUrl);
|
return new XiboTestResult { IsValid = false, Message = "Connection timed out." };
|
||||||
return new XiboTestResult
|
|
||||||
{
|
|
||||||
IsValid = false,
|
|
||||||
Message = "Connection timed out. Xibo instance may not be running.",
|
|
||||||
HttpStatus = 0
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex)
|
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}" };
|
||||||
return new XiboTestResult
|
|
||||||
{
|
|
||||||
IsValid = false,
|
|
||||||
Message = $"Cannot reach Xibo instance: {ex.Message}",
|
|
||||||
HttpStatus = 0
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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
|
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
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
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
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -14,9 +16,13 @@ Global
|
|||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{A1B2C3D4-1111-2222-3333-444455556666}.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}.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
|
EndGlobalSection
|
||||||
EndGlobal
|
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"
|
@page "/instances/create"
|
||||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
|
||||||
@inject InstanceService InstanceSvc
|
@inject InstanceService InstanceSvc
|
||||||
@inject XiboApiService XiboApi
|
|
||||||
@inject NavigationManager Navigation
|
@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>
|
<PageTitle>Create Instance - OTS Signs Orchestrator</PageTitle>
|
||||||
|
|
||||||
<h3>Create CMS Instance</h3>
|
<h3>Create CMS Instance</h3>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-6">
|
||||||
<EditForm Model="model" OnValidSubmit="HandleSubmit" FormName="createInstance">
|
<EditForm Model="model" OnValidSubmit="HandleSubmit" FormName="createInstance">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<ValidationSummary class="text-danger" />
|
<ValidationSummary class="text-danger" />
|
||||||
|
|
||||||
<fieldset disabled="@deploying">
|
<fieldset disabled="@deploying">
|
||||||
@* Customer Details *@
|
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">Customer Details</div>
|
<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="card-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Theme Host Path</label>
|
<label class="form-label fw-semibold">Customer Name</label>
|
||||||
<InputText @bind-Value="model.ThemeHostPath" class="form-control" placeholder="/data/xibo-theme" />
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Library Host Path</label>
|
<label class="form-label fw-semibold">3-Letter Abbreviation</label>
|
||||||
<InputText @bind-Value="model.LibraryHostPath" class="form-control" placeholder="/data/xibo-library" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* SMTP *@
|
@* Show what will be auto-configured from settings *@
|
||||||
<div class="card mb-3">
|
<div class="card mb-3 border-secondary">
|
||||||
<div class="card-header">SMTP Settings</div>
|
<div class="card-header bg-light text-muted">Auto-configured from Settings</div>
|
||||||
<div class="card-body">
|
<div class="card-body text-muted small">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-6 mb-1"><strong>CMS server:</strong><br />@(Defaults.Value.CmsServerNameTemplate.Replace("{abbrev}", AbbrevPreview))</div>
|
||||||
<label class="form-label">SMTP Server</label>
|
<div class="col-6 mb-1"><strong>Theme path:</strong><br />@Defaults.Value.ThemeHostPath</div>
|
||||||
<InputText @bind-Value="model.SmtpServer" class="form-control" placeholder="smtp.example.com" />
|
<div class="col-6 mb-1"><strong>MySQL DB:</strong><br />@(Defaults.Value.MySqlDatabaseTemplate.Replace("{abbrev}", AbbrevPreview))</div>
|
||||||
</div>
|
<div class="col-6 mb-1"><strong>MySQL user:</strong><br />@(Defaults.Value.MySqlUserTemplate.Replace("{abbrev}", AbbrevPreview))</div>
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-6 mb-1"><strong>SMTP server:</strong><br />@Defaults.Value.SmtpServer</div>
|
||||||
<label class="form-label">SMTP Username</label>
|
<div class="col-6 mb-1"><strong>Template repo:</strong><br />@(string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl) ? "⚠️ Not configured" : Defaults.Value.TemplateRepoUrl)</div>
|
||||||
<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>
|
</div>
|
||||||
|
<a href="/settings" class="small">Edit defaults in Settings</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Git Template *@
|
@* Optional overrides *@
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">Git Template Source</div>
|
<div class="card-header">Optional Overrides</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="mb-3">
|
||||||
<div class="col-md-8 mb-3">
|
<label class="form-label">Template Repo URL <span class="text-muted">(overrides setting)</span></label>
|
||||||
<label class="form-label">Template Repo URL</label>
|
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control"
|
||||||
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control" placeholder="https://github.com/org/xibo-templates.git" />
|
placeholder="Leave blank to use configured default" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">PAT / Token (optional)</label>
|
<label class="form-label">Theme Host Path <span class="text-muted">(overrides setting default: /cms/ots-theme)</span></label>
|
||||||
<InputText @bind-Value="model.TemplateRepoPat" class="form-control" type="password" placeholder="Leave empty for public repos" />
|
<InputText @bind-Value="model.ThemeHostPath" class="form-control"
|
||||||
|
placeholder="/cms/ots-theme" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="mb-3">
|
||||||
</div>
|
<label class="form-label">Placement Constraints <span class="text-muted">(comma-separated)</span></label>
|
||||||
</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>
|
|
||||||
<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" />
|
|
||||||
</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" />
|
|
||||||
</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"
|
<InputText @bind-Value="constraintsText" class="form-control"
|
||||||
placeholder="node.labels.xibo==true, node.role==manager" />
|
placeholder="node.labels.xibo==true, node.role==manager" />
|
||||||
<small class="text-muted">Comma-separated placement constraints</small>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Xibo Credentials (optional) *@
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Xibo API Credentials <span class="text-muted fw-normal">(optional)</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">Provide credentials to enable API connectivity testing after deploy.</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Client ID</label>
|
||||||
|
<InputText @bind-Value="model.XiboUsername" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Client Secret</label>
|
||||||
|
<InputText @bind-Value="model.XiboPassword" class="form-control" type="password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Actions *@
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-success" disabled="@deploying">
|
<button type="submit" class="btn btn-success" disabled="@deploying">
|
||||||
@(deploying ? "Deploying..." : "Deploy Instance")
|
@(deploying ? "Deploying…" : "Deploy Instance")
|
||||||
</button>
|
</button>
|
||||||
<a href="/" class="btn btn-secondary">Cancel</a>
|
<a href="/" class="btn btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,26 +391,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private CreateInstanceDto model = new()
|
private CreateInstanceDto model = new();
|
||||||
{
|
|
||||||
HostHttpPort = 8080
|
|
||||||
};
|
|
||||||
|
|
||||||
private string? constraintsText;
|
private string? constraintsText;
|
||||||
private bool deploying;
|
private bool deploying;
|
||||||
private string? resultMessage;
|
private string? resultMessage;
|
||||||
private bool resultSuccess;
|
private bool resultSuccess;
|
||||||
private Guid? createdInstanceId;
|
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()
|
private async Task HandleSubmit()
|
||||||
{
|
{
|
||||||
deploying = true;
|
deploying = true;
|
||||||
resultMessage = null;
|
resultMessage = null;
|
||||||
|
model.CustomerAbbrev = model.CustomerAbbrev?.ToUpperInvariant() ?? string.Empty;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Parse constraints
|
|
||||||
if (!string.IsNullOrWhiteSpace(constraintsText))
|
if (!string.IsNullOrWhiteSpace(constraintsText))
|
||||||
{
|
{
|
||||||
model.Constraints = constraintsText
|
model.Constraints = constraintsText
|
||||||
@@ -198,7 +442,7 @@
|
|||||||
|
|
||||||
if (result.Success)
|
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;
|
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 const string SectionName = "Database";
|
||||||
public string Provider { get; set; } = "Sqlite"; // Sqlite or PostgreSQL
|
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<DockerOptions>(config.GetSection(DockerOptions.SectionName));
|
||||||
builder.Services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
builder.Services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
||||||
builder.Services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.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 ---
|
// --- Serilog ---
|
||||||
ConfigureSerilog(builder);
|
ConfigureSerilog(builder);
|
||||||
@@ -66,6 +69,7 @@ public static class DependencyInjection
|
|||||||
builder.Services.AddScoped<ComposeValidationService>();
|
builder.Services.AddScoped<ComposeValidationService>();
|
||||||
builder.Services.AddScoped<DockerCliService>();
|
builder.Services.AddScoped<DockerCliService>();
|
||||||
builder.Services.AddScoped<DockerSecretsService>();
|
builder.Services.AddScoped<DockerSecretsService>();
|
||||||
|
builder.Services.AddScoped<MySqlProvisionService>();
|
||||||
builder.Services.AddScoped<XiboApiService>();
|
builder.Services.AddScoped<XiboApiService>();
|
||||||
builder.Services.AddScoped<InstanceService>();
|
builder.Services.AddScoped<InstanceService>();
|
||||||
builder.Services.AddScoped<OidcProviderService>();
|
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
|
public class CreateInstanceDto
|
||||||
{
|
{
|
||||||
|
/// <summary>Full display name of the customer (stored as YAML comment).</summary>
|
||||||
[Required, MaxLength(100)]
|
[Required, MaxLength(100)]
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Required, MaxLength(100)]
|
/// <summary>3-letter uppercase abbreviation used as prefix in all stack resource names.</summary>
|
||||||
public string StackName { get; set; } = string.Empty;
|
[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)]
|
// TemplateRepoUrl and TemplateRepoPat are sourced from app settings (Settings page) and
|
||||||
public string CmsServerName { get; set; } = string.Empty;
|
// optionally overridden here per-instance.
|
||||||
|
[MaxLength(500)]
|
||||||
[Required, Range(1024, 65535)]
|
public string? TemplateRepoUrl { get; set; }
|
||||||
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;
|
|
||||||
|
|
||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string? TemplateRepoPat { get; set; }
|
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>
|
/// <summary>Comma-separated placement constraints.</summary>
|
||||||
public List<string>? Constraints { get; set; }
|
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)]
|
[Required, MaxLength(100)]
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
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)]
|
[Required, MaxLength(100)]
|
||||||
public string StackName { get; set; } = string.Empty;
|
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;
|
namespace OTSSignsOrchestrator.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders a Compose v3.8 YAML from a template, merged with user inputs,
|
/// Renders a single self-contained Compose v3.9 YAML from the template and user/settings inputs.
|
||||||
/// secrets references, bind mounts, and placement constraints.
|
/// 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>
|
/// </summary>
|
||||||
public class ComposeRenderService
|
public class ComposeRenderService
|
||||||
{
|
{
|
||||||
private readonly XiboOptions _xiboOptions;
|
private readonly XiboOptions _xiboOptions;
|
||||||
private readonly DockerOptions _dockerOptions;
|
private readonly DockerOptions _dockerOptions;
|
||||||
|
private readonly CifsOptions _cifsOptions;
|
||||||
private readonly ILogger<ComposeRenderService> _logger;
|
private readonly ILogger<ComposeRenderService> _logger;
|
||||||
|
|
||||||
public ComposeRenderService(
|
public ComposeRenderService(
|
||||||
IOptions<XiboOptions> xiboOptions,
|
IOptions<XiboOptions> xiboOptions,
|
||||||
IOptions<DockerOptions> dockerOptions,
|
IOptions<DockerOptions> dockerOptions,
|
||||||
|
IOptions<CifsOptions> cifsOptions,
|
||||||
ILogger<ComposeRenderService> logger)
|
ILogger<ComposeRenderService> logger)
|
||||||
{
|
{
|
||||||
_xiboOptions = xiboOptions.Value;
|
_xiboOptions = xiboOptions.Value;
|
||||||
_dockerOptions = dockerOptions.Value;
|
_dockerOptions = dockerOptions.Value;
|
||||||
|
_cifsOptions = cifsOptions.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +38,7 @@ public class ComposeRenderService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string Render(RenderContext ctx)
|
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
|
// Parse template YAML
|
||||||
var yaml = new YamlStream();
|
var yaml = new YamlStream();
|
||||||
@@ -45,7 +50,7 @@ public class ComposeRenderService
|
|||||||
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
|
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
|
||||||
|
|
||||||
// Ensure version
|
// Ensure version
|
||||||
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.8");
|
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
|
||||||
|
|
||||||
// Process services
|
// Process services
|
||||||
EnsureServices(root, ctx);
|
EnsureServices(root, ctx);
|
||||||
@@ -57,17 +62,16 @@ public class ComposeRenderService
|
|||||||
EnsureSecrets(root, ctx);
|
EnsureSecrets(root, ctx);
|
||||||
|
|
||||||
// Serialize back to YAML
|
// Serialize back to YAML
|
||||||
var serializer = new SerializerBuilder()
|
|
||||||
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
using var writer = new StringWriter();
|
using var writer = new StringWriter();
|
||||||
yaml.Save(writer, assignAnchors: false);
|
yaml.Save(writer, assignAnchors: false);
|
||||||
var output = writer.ToString();
|
var output = writer.ToString();
|
||||||
|
|
||||||
// Clean up YAML artifacts
|
// Clean up YAML stream terminators
|
||||||
output = output.Replace("...\n", "").Replace("...", "");
|
output = output.Replace("...\n", "").Replace("...", "");
|
||||||
|
|
||||||
|
// Prepend customer comment
|
||||||
|
output = $"# Customer: {ctx.CustomerName}\n" + output;
|
||||||
|
|
||||||
_logger.LogDebug("Compose rendered: {ServiceCount} services, {SecretCount} secrets",
|
_logger.LogDebug("Compose rendered: {ServiceCount} services, {SecretCount} secrets",
|
||||||
GetServiceCount(root), ctx.SecretNames.Count);
|
GetServiceCount(root), ctx.SecretNames.Count);
|
||||||
|
|
||||||
@@ -81,257 +85,265 @@ public class ComposeRenderService
|
|||||||
|
|
||||||
var services = (YamlMappingNode)root.Children[new YamlScalarNode("services")];
|
var services = (YamlMappingNode)root.Children[new YamlScalarNode("services")];
|
||||||
|
|
||||||
// CMS Database (MySQL)
|
// Clear any services from template — we always build them deterministically
|
||||||
EnsureCmsDb(services, ctx);
|
services.Children.Clear();
|
||||||
|
|
||||||
// CMS Web (Xibo)
|
|
||||||
EnsureCmsWeb(services, ctx);
|
EnsureCmsWeb(services, ctx);
|
||||||
|
|
||||||
// Memcached
|
|
||||||
EnsureMemcached(services, ctx);
|
EnsureMemcached(services, ctx);
|
||||||
|
|
||||||
// QuickChart
|
|
||||||
EnsureQuickChart(services, ctx);
|
EnsureQuickChart(services, ctx);
|
||||||
|
EnsureNewt(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureCmsWeb(YamlMappingNode services, RenderContext ctx)
|
private void EnsureCmsWeb(YamlMappingNode services, RenderContext ctx)
|
||||||
{
|
{
|
||||||
var key = new YamlScalarNode("cms-web");
|
var a = ctx.CustomerAbbrev;
|
||||||
YamlMappingNode svc;
|
var svc = new YamlMappingNode();
|
||||||
|
services.Children[new YamlScalarNode($"{a}-web")] = 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.Cms);
|
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Cms);
|
||||||
|
|
||||||
// Merge template env with overrides
|
// Build environment — merge template.env first, then apply our required overrides
|
||||||
var env = new YamlMappingNode();
|
var env = BuildEnvFromTemplate(ctx.TemplateEnvValues, ctx);
|
||||||
|
|
||||||
// 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");
|
|
||||||
|
|
||||||
svc.Children[new YamlScalarNode("environment")] = env;
|
svc.Children[new YamlScalarNode("environment")] = env;
|
||||||
|
|
||||||
// Ports
|
// Ports
|
||||||
var ports = new YamlSequenceNode(
|
svc.Children[new YamlScalarNode("ports")] = new YamlSequenceNode(
|
||||||
new YamlScalarNode($"{ctx.HostHttpPort}:80")
|
new YamlScalarNode($"{ctx.HostHttpPort}:80")
|
||||||
);
|
);
|
||||||
svc.Children[new YamlScalarNode("ports")] = ports;
|
|
||||||
|
|
||||||
// Volumes (bind mounts + named volumes)
|
// Named volumes (CIFS-backed) + theme bind mount
|
||||||
var volumes = new YamlSequenceNode(
|
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.ThemeHostPath}:/var/www/cms/web/theme/custom"),
|
||||||
new YamlScalarNode($"{ctx.LibraryHostPath}:/var/www/cms/library"),
|
new YamlScalarNode($"{a}-cms-library:/var/www/cms/library"),
|
||||||
new YamlScalarNode($"{AppConstants.SanitizeName(ctx.CustomerName)}_cms_backup:/var/www/cms/backup"),
|
new YamlScalarNode($"{a}-cms-userscripts:/var/www/cms/web/userscripts"),
|
||||||
new YamlScalarNode($"{AppConstants.SanitizeName(ctx.CustomerName)}_cms_custom:/var/www/cms/custom")
|
new YamlScalarNode($"{a}-cms-ca-certs:/var/www/cms/ca-certs")
|
||||||
);
|
);
|
||||||
svc.Children[new YamlScalarNode("volumes")] = volumes;
|
|
||||||
|
|
||||||
// Secrets
|
// Secrets
|
||||||
var secrets = new YamlSequenceNode(
|
svc.Children[new YamlScalarNode("secrets")] = new YamlSequenceNode(
|
||||||
new YamlScalarNode(AppConstants.CustomerMysqlSecretName(ctx.CustomerName)),
|
ctx.SecretNames.Select(n => (YamlNode)new YamlScalarNode(n)).ToList()
|
||||||
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")
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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)
|
private void EnsureMemcached(YamlMappingNode services, RenderContext ctx)
|
||||||
{
|
{
|
||||||
var key = new YamlScalarNode("cms-memcached");
|
var a = ctx.CustomerAbbrev;
|
||||||
YamlMappingNode svc;
|
var svc = new YamlMappingNode();
|
||||||
|
services.Children[new YamlScalarNode($"{a}-memcached")] = 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.Memcached);
|
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)
|
private void EnsureQuickChart(YamlMappingNode services, RenderContext ctx)
|
||||||
{
|
{
|
||||||
var key = new YamlScalarNode("cms-quickchart");
|
var a = ctx.CustomerAbbrev;
|
||||||
YamlMappingNode svc;
|
var svc = new YamlMappingNode();
|
||||||
|
services.Children[new YamlScalarNode($"{a}-quickchart")] = 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.QuickChart);
|
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;
|
return;
|
||||||
|
|
||||||
var deployKey = new YamlScalarNode("deploy");
|
var svc = new YamlMappingNode();
|
||||||
YamlMappingNode deploy;
|
services.Children[new YamlScalarNode($"{a}-newt")] = svc;
|
||||||
if (service.Children.ContainsKey(deployKey))
|
|
||||||
deploy = (YamlMappingNode)service.Children[deployKey];
|
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode("fosrl/newt");
|
||||||
else
|
|
||||||
{
|
var env = new YamlMappingNode();
|
||||||
deploy = new YamlMappingNode();
|
if (ctx.TemplateEnvValues.TryGetValue("PANGOLIN_ENDPOINT", out var endpoint))
|
||||||
service.Children[deployKey] = deploy;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
var placementKey = new YamlScalarNode("placement");
|
/// <summary>
|
||||||
YamlMappingNode placement;
|
/// Build the environment mapping for cms-web: start with template.env values,
|
||||||
if (deploy.Children.ContainsKey(placementKey))
|
/// then apply all required orchestrator overrides.
|
||||||
placement = (YamlMappingNode)deploy.Children[placementKey];
|
/// </summary>
|
||||||
else
|
private YamlMappingNode BuildEnvFromTemplate(Dictionary<string, string> templateEnv, RenderContext ctx)
|
||||||
{
|
{
|
||||||
placement = new YamlMappingNode();
|
var env = new YamlMappingNode();
|
||||||
deploy.Children[placementKey] = placement;
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
var constraintNodes = ctx.Constraints
|
private static YamlMappingNode BuildDeploy(RenderContext ctx, string? memoryLimit = null)
|
||||||
.Select(c => (YamlNode)new YamlScalarNode(c))
|
{
|
||||||
.ToList();
|
var deploy = new YamlMappingNode();
|
||||||
|
|
||||||
placement.Children[new YamlScalarNode("constraints")] = new YamlSequenceNode(constraintNodes);
|
var restartPolicy = new YamlMappingNode();
|
||||||
|
restartPolicy.Children[new YamlScalarNode("condition")] = new YamlScalarNode("any");
|
||||||
|
deploy.Children[new YamlScalarNode("restart_policy")] = restartPolicy;
|
||||||
|
|
||||||
|
if (memoryLimit != null)
|
||||||
|
{
|
||||||
|
var resources = new YamlMappingNode();
|
||||||
|
var limits = new YamlMappingNode();
|
||||||
|
limits.Children[new YamlScalarNode("memory")] = new YamlScalarNode(memoryLimit);
|
||||||
|
resources.Children[new YamlScalarNode("limits")] = limits;
|
||||||
|
deploy.Children[new YamlScalarNode("resources")] = resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.Constraints != null && ctx.Constraints.Count > 0)
|
||||||
|
{
|
||||||
|
var placement = new YamlMappingNode();
|
||||||
|
placement.Children[new YamlScalarNode("constraints")] = new YamlSequenceNode(
|
||||||
|
ctx.Constraints.Select(c => (YamlNode)new YamlScalarNode(c)).ToList()
|
||||||
|
);
|
||||||
|
deploy.Children[new YamlScalarNode("placement")] = placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deploy;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureVolumes(YamlMappingNode root, RenderContext ctx)
|
private void EnsureVolumes(YamlMappingNode root, RenderContext ctx)
|
||||||
{
|
{
|
||||||
|
var a = ctx.CustomerAbbrev;
|
||||||
var volumesKey = new YamlScalarNode("volumes");
|
var volumesKey = new YamlScalarNode("volumes");
|
||||||
YamlMappingNode volumes;
|
var volumes = new YamlMappingNode();
|
||||||
|
|
||||||
if (root.Children.ContainsKey(volumesKey))
|
|
||||||
volumes = (YamlMappingNode)root.Children[volumesKey];
|
|
||||||
else
|
|
||||||
{
|
|
||||||
volumes = new YamlMappingNode();
|
|
||||||
root.Children[volumesKey] = volumes;
|
root.Children[volumesKey] = volumes;
|
||||||
|
|
||||||
|
// CIFS-backed named volumes
|
||||||
|
var cifsVolumes = new[] { "cms-custom", "cms-backup", "cms-library", "cms-userscripts", "cms-ca-certs" };
|
||||||
|
foreach (var vol in cifsVolumes)
|
||||||
|
{
|
||||||
|
var volName = $"{a}-{vol}";
|
||||||
|
volumes.Children[new YamlScalarNode(volName)] = BuildCifsVolumeNode(vol, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
/// <summary>
|
||||||
volumes.Children[new YamlScalarNode($"{prefix}_cms_db")] = new YamlMappingNode();
|
/// Build a CIFS-driver volume node matching the pattern:
|
||||||
volumes.Children[new YamlScalarNode($"{prefix}_cms_backup")] = new YamlMappingNode();
|
/// <code>
|
||||||
volumes.Children[new YamlScalarNode($"{prefix}_cms_custom")] = new YamlMappingNode();
|
/// 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)
|
private void EnsureSecrets(YamlMappingNode root, RenderContext ctx)
|
||||||
{
|
{
|
||||||
var secretsKey = new YamlScalarNode("secrets");
|
var secretsKey = new YamlScalarNode("secrets");
|
||||||
YamlMappingNode secrets;
|
var secrets = new YamlMappingNode();
|
||||||
|
|
||||||
if (root.Children.ContainsKey(secretsKey))
|
|
||||||
secrets = (YamlMappingNode)root.Children[secretsKey];
|
|
||||||
else
|
|
||||||
{
|
|
||||||
secrets = new YamlMappingNode();
|
|
||||||
root.Children[secretsKey] = secrets;
|
root.Children[secretsKey] = secrets;
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var secretName in ctx.SecretNames)
|
foreach (var secretName in ctx.SecretNames)
|
||||||
{
|
{
|
||||||
var secretNode = new YamlMappingNode
|
secrets.Children[new YamlScalarNode(secretName)] = new YamlMappingNode
|
||||||
{
|
{
|
||||||
{ "external", "true" }
|
{ "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)
|
private static int GetServiceCount(YamlMappingNode root)
|
||||||
{
|
{
|
||||||
var servicesKey = new YamlScalarNode("services");
|
var servicesKey = new YamlScalarNode("services");
|
||||||
@@ -342,22 +354,41 @@ public class ComposeRenderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Context object with all inputs needed to render a Compose file.
|
/// All inputs needed to render a single Compose file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RenderContext
|
public class RenderContext
|
||||||
{
|
{
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
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 StackName { get; set; } = string.Empty;
|
||||||
public string CmsServerName { get; set; } = string.Empty;
|
public string CmsServerName { get; set; } = string.Empty;
|
||||||
public int HostHttpPort { get; set; }
|
public int HostHttpPort { get; set; }
|
||||||
public string ThemeHostPath { get; set; } = string.Empty;
|
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 SmtpServer { get; set; } = string.Empty;
|
||||||
public string SmtpUsername { get; set; } = string.Empty;
|
public string SmtpUsername { get; set; } = string.Empty;
|
||||||
public string TemplateYaml { get; set; } = string.Empty;
|
public string SmtpPassword { get; set; } = string.Empty;
|
||||||
public List<string> TemplateEnvLines { get; set; } = new();
|
public string SmtpRewriteDomain { get; set; } = string.Empty;
|
||||||
public List<string> Constraints { get; set; } = new();
|
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>Secret names to declare as external in the compose file.</summary>
|
||||||
public List<string> SecretNames { get; set; } = new();
|
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>
|
/// <summary>
|
||||||
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
|
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
|
||||||
/// Coordinates GitTemplateService, ComposeRenderService, ComposeValidationService,
|
/// Coordinates GitTemplateService, ComposeRenderService, ComposeValidationService,
|
||||||
/// DockerCliService, DockerSecretsService, and XiboApiService.
|
/// DockerCliService, DockerSecretsService, MySqlProvisionService, and XiboApiService.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class InstanceService
|
public class InstanceService
|
||||||
{
|
{
|
||||||
@@ -23,8 +23,12 @@ public class InstanceService
|
|||||||
private readonly ComposeValidationService _validation;
|
private readonly ComposeValidationService _validation;
|
||||||
private readonly DockerCliService _docker;
|
private readonly DockerCliService _docker;
|
||||||
private readonly DockerSecretsService _secrets;
|
private readonly DockerSecretsService _secrets;
|
||||||
|
private readonly MySqlProvisionService _mysql;
|
||||||
private readonly XiboApiService _xibo;
|
private readonly XiboApiService _xibo;
|
||||||
private readonly DockerOptions _dockerOptions;
|
private readonly DockerOptions _dockerOptions;
|
||||||
|
private readonly MySqlAdminOptions _mysqlAdminOptions;
|
||||||
|
private readonly CifsOptions _cifsOptions;
|
||||||
|
private readonly InstanceDefaultsOptions _instanceDefaults;
|
||||||
private readonly ILogger<InstanceService> _logger;
|
private readonly ILogger<InstanceService> _logger;
|
||||||
|
|
||||||
public InstanceService(
|
public InstanceService(
|
||||||
@@ -34,8 +38,12 @@ public class InstanceService
|
|||||||
ComposeValidationService validation,
|
ComposeValidationService validation,
|
||||||
DockerCliService docker,
|
DockerCliService docker,
|
||||||
DockerSecretsService secrets,
|
DockerSecretsService secrets,
|
||||||
|
MySqlProvisionService mysql,
|
||||||
XiboApiService xibo,
|
XiboApiService xibo,
|
||||||
IOptions<DockerOptions> dockerOptions,
|
IOptions<DockerOptions> dockerOptions,
|
||||||
|
IOptions<MySqlAdminOptions> mysqlAdminOptions,
|
||||||
|
IOptions<CifsOptions> cifsOptions,
|
||||||
|
IOptions<InstanceDefaultsOptions> instanceDefaults,
|
||||||
ILogger<InstanceService> logger)
|
ILogger<InstanceService> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
@@ -44,67 +52,129 @@ public class InstanceService
|
|||||||
_validation = validation;
|
_validation = validation;
|
||||||
_docker = docker;
|
_docker = docker;
|
||||||
_secrets = secrets;
|
_secrets = secrets;
|
||||||
|
_mysql = mysql;
|
||||||
_xibo = xibo;
|
_xibo = xibo;
|
||||||
_dockerOptions = dockerOptions.Value;
|
_dockerOptions = dockerOptions.Value;
|
||||||
|
_mysqlAdminOptions = mysqlAdminOptions.Value;
|
||||||
|
_cifsOptions = cifsOptions.Value;
|
||||||
|
_instanceDefaults = instanceDefaults.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create and deploy a new CMS instance.
|
/// 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>
|
/// </summary>
|
||||||
public async Task<DeploymentResultDto> CreateInstanceAsync(CreateInstanceDto dto, string? userId = null, string? ipAddress = null)
|
public async Task<DeploymentResultDto> CreateInstanceAsync(CreateInstanceDto dto, string? userId = null, string? ipAddress = null)
|
||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
var opLog = StartOperation(OperationType.Create, userId, ipAddress);
|
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
|
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()
|
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)
|
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 template = await _git.FetchAsync(templateRepoUrl, templateRepoPat);
|
||||||
var smtpResult = await _secrets.EnsureSecretAsync(AppConstants.GlobalSmtpSecretName, dto.SmtpPassword);
|
|
||||||
|
// 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 mysqlPassword = GenerateRandomPassword(32);
|
||||||
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(dto.CustomerName);
|
|
||||||
var mysqlResult = await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
|
|
||||||
|
|
||||||
// Track secrets in DB
|
var mysqlSecretResult = await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
|
||||||
await EnsureSecretMetadata(AppConstants.GlobalSmtpSecretName, true, null);
|
_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);
|
await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName);
|
||||||
|
|
||||||
// 4. Build render context
|
// ----------------------------------------------------------------
|
||||||
var constraints = dto.Constraints ?? new List<string>();
|
// Step 4 — Provision MySQL database and user
|
||||||
if (constraints.Count == 0)
|
// ----------------------------------------------------------------
|
||||||
constraints = _dockerOptions.DefaultConstraints;
|
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
|
var renderCtx = new RenderContext
|
||||||
{
|
{
|
||||||
CustomerName = dto.CustomerName,
|
CustomerName = dto.CustomerName,
|
||||||
StackName = dto.StackName,
|
CustomerAbbrev = abbrev,
|
||||||
CmsServerName = dto.CmsServerName,
|
StackName = stackName,
|
||||||
HostHttpPort = dto.HostHttpPort,
|
CmsServerName = cmsServerName,
|
||||||
ThemeHostPath = dto.ThemeHostPath,
|
HostHttpPort = hostHttpPort,
|
||||||
LibraryHostPath = dto.LibraryHostPath,
|
ThemeHostPath = themeHostPath,
|
||||||
SmtpServer = dto.SmtpServer,
|
MySqlHost = _mysqlAdminOptions.Host,
|
||||||
SmtpUsername = dto.SmtpUsername,
|
MySqlPort = _mysqlAdminOptions.Port,
|
||||||
|
MySqlDatabase = mysqlDatabase,
|
||||||
|
MySqlUser = mysqlUser,
|
||||||
|
SmtpServer = _instanceDefaults.SmtpServer,
|
||||||
|
SmtpUsername = _instanceDefaults.SmtpUsername,
|
||||||
|
SmtpPassword = _instanceDefaults.SmtpPassword,
|
||||||
|
SmtpRewriteDomain = smtpRewriteDomain,
|
||||||
TemplateYaml = template.Yaml,
|
TemplateYaml = template.Yaml,
|
||||||
TemplateEnvLines = template.EnvLines,
|
TemplateEnvValues = templateEnvValues,
|
||||||
Constraints = constraints,
|
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);
|
var composeYaml = _compose.Render(renderCtx);
|
||||||
|
|
||||||
// 6. Validate Compose
|
// ----------------------------------------------------------------
|
||||||
|
// Step 6 — Validate Compose
|
||||||
|
// ----------------------------------------------------------------
|
||||||
if (_dockerOptions.ValidateBeforeDeploy)
|
if (_dockerOptions.ValidateBeforeDeploy)
|
||||||
{
|
{
|
||||||
var validationResult = _validation.Validate(composeYaml);
|
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)
|
if (!deployResult.Success)
|
||||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
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
|
var instance = new CmsInstance
|
||||||
{
|
{
|
||||||
CustomerName = dto.CustomerName,
|
CustomerName = dto.CustomerName,
|
||||||
StackName = dto.StackName,
|
CustomerAbbrev = abbrev,
|
||||||
CmsServerName = dto.CmsServerName,
|
StackName = stackName,
|
||||||
HostHttpPort = dto.HostHttpPort,
|
CmsServerName = cmsServerName,
|
||||||
ThemeHostPath = dto.ThemeHostPath,
|
HostHttpPort = hostHttpPort,
|
||||||
LibraryHostPath = dto.LibraryHostPath,
|
ThemeHostPath = themeHostPath,
|
||||||
SmtpServer = dto.SmtpServer,
|
LibraryHostPath = _instanceDefaults.LibraryShareSubPath.Replace("{abbrev}", abbrev),
|
||||||
SmtpUsername = dto.SmtpUsername,
|
SmtpServer = _instanceDefaults.SmtpServer,
|
||||||
|
SmtpUsername = _instanceDefaults.SmtpUsername,
|
||||||
Constraints = JsonSerializer.Serialize(constraints),
|
Constraints = JsonSerializer.Serialize(constraints),
|
||||||
TemplateRepoUrl = dto.TemplateRepoUrl,
|
TemplateRepoUrl = templateRepoUrl,
|
||||||
TemplateRepoPat = dto.TemplateRepoPat,
|
TemplateRepoPat = templateRepoPat,
|
||||||
TemplateLastFetch = template.FetchedAt,
|
TemplateLastFetch = template.FetchedAt,
|
||||||
Status = InstanceStatus.Active,
|
Status = InstanceStatus.Active,
|
||||||
XiboUsername = dto.XiboUsername,
|
XiboUsername = dto.XiboUsername,
|
||||||
@@ -146,14 +221,14 @@ public class InstanceService
|
|||||||
sw.Stop();
|
sw.Stop();
|
||||||
opLog.InstanceId = instance.Id;
|
opLog.InstanceId = instance.Id;
|
||||||
opLog.Status = OperationStatus.Success;
|
opLog.Status = OperationStatus.Success;
|
||||||
opLog.Message = $"Instance deployed: {dto.StackName}";
|
opLog.Message = $"Instance deployed: {stackName}";
|
||||||
opLog.DurationMs = sw.ElapsedMilliseconds;
|
opLog.DurationMs = sw.ElapsedMilliseconds;
|
||||||
_db.OperationLogs.Add(opLog);
|
_db.OperationLogs.Add(opLog);
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
|
_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.ServiceCount = 4;
|
||||||
deployResult.Message = "Instance deployed successfully.";
|
deployResult.Message = "Instance deployed successfully.";
|
||||||
@@ -168,7 +243,7 @@ public class InstanceService
|
|||||||
_db.OperationLogs.Add(opLog);
|
_db.OperationLogs.Add(opLog);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
_logger.LogError(ex, "Instance create failed: {StackName}", dto.StackName);
|
_logger.LogError(ex, "Instance create failed: abbrev={Abbrev}", abbrev);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,20 +283,31 @@ public class InstanceService
|
|||||||
|
|
||||||
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(instance.CustomerName);
|
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
|
var renderCtx = new RenderContext
|
||||||
{
|
{
|
||||||
CustomerName = instance.CustomerName,
|
CustomerName = instance.CustomerName,
|
||||||
|
CustomerAbbrev = abbrev,
|
||||||
StackName = instance.StackName,
|
StackName = instance.StackName,
|
||||||
CmsServerName = instance.CmsServerName,
|
CmsServerName = instance.CmsServerName,
|
||||||
HostHttpPort = instance.HostHttpPort,
|
HostHttpPort = instance.HostHttpPort,
|
||||||
ThemeHostPath = instance.ThemeHostPath,
|
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,
|
SmtpServer = instance.SmtpServer,
|
||||||
SmtpUsername = instance.SmtpUsername,
|
SmtpUsername = instance.SmtpUsername,
|
||||||
|
SmtpPassword = _instanceDefaults.SmtpPassword,
|
||||||
|
SmtpRewriteDomain = smtpRewriteDomain,
|
||||||
TemplateYaml = template.Yaml,
|
TemplateYaml = template.Yaml,
|
||||||
TemplateEnvLines = template.EnvLines,
|
TemplateEnvValues = templateEnvValues,
|
||||||
Constraints = constraints,
|
Constraints = constraints,
|
||||||
SecretNames = new List<string> { AppConstants.GlobalSmtpSecretName, mysqlSecretName }
|
SecretNames = new List<string> { mysqlSecretName },
|
||||||
|
CifsCredentialsFilePath = "/etc/docker-cifs-credentials"
|
||||||
};
|
};
|
||||||
|
|
||||||
var composeYaml = _compose.Render(renderCtx);
|
var composeYaml = _compose.Render(renderCtx);
|
||||||
@@ -430,4 +516,36 @@ public class InstanceService
|
|||||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||||
return RandomNumberGenerator.GetString(chars, length);
|
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