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; /// /// ViewModel for the Create Instance form. /// Simplified: Customer Name, Abbreviation, SSH Host, and optional Newt credentials. /// All other config comes from the Settings page. /// 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 _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(); var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync(); AvailableHosts = new ObservableCollection(hosts); } private async Task LoadCifsDefaultsAsync() { using var scope = _services.CreateScope(); var settings = scope.ServiceProvider.GetRequiredService(); 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(); dockerCli.SetHost(SelectedSshHost); var dockerSecrets = _services.GetRequiredService(); dockerSecrets.SetHost(SelectedSshHost); var ssh = _services.GetRequiredService(); using var scope = _services.CreateScope(); var instanceSvc = scope.ServiceProvider.GetRequiredService(); // ── 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); } }