Add WAL file for database and log instance deployment failures
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
Matt Batchelder
2026-02-19 08:27:54 -05:00
parent 4a903bfd2a
commit adf1a2e4db
41 changed files with 2789 additions and 1297 deletions

View File

@@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
@@ -35,18 +37,23 @@ public partial class CreateInstanceViewModel : ObservableObject
[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 _cifsShareName = string.Empty;
[ObservableProperty] private string _cifsShareFolder = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
// NFS volume settings (per-instance, defaults loaded from global settings)
[ObservableProperty] private string _nfsServer = string.Empty;
[ObservableProperty] private string _nfsExport = string.Empty;
[ObservableProperty] private string _nfsExportFolder = string.Empty;
[ObservableProperty] private string _nfsExtraOptions = string.Empty;
// SSH host selection
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
// YML preview
[ObservableProperty] private string _previewYml = string.Empty;
[ObservableProperty] private bool _isLoadingYml;
public bool HasPreviewYml => !string.IsNullOrEmpty(PreviewYml);
partial void OnPreviewYmlChanged(string value) => OnPropertyChanged(nameof(HasPreviewYml));
// ── Derived preview properties ───────────────────────────────────────────
public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—";
@@ -55,14 +62,17 @@ public partial class CreateInstanceViewModel : ObservableObject
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 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 PreviewSecretUser => Valid ? $"{Abbrev}-cms-db-user" : "—";
public string PreviewSecretHost => "global_mysql_host";
public string PreviewSecretPort => "global_mysql_port";
public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—";
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms" : "—";
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms_user" : "—";
public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—";
private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant();
@@ -74,7 +84,7 @@ public partial class CreateInstanceViewModel : ObservableObject
{
_services = services;
_ = LoadHostsAsync();
_ = LoadCifsDefaultsAsync();
_ = LoadNfsDefaultsAsync();
}
partial void OnCustomerAbbrevChanged(string value) => RefreshPreview();
@@ -93,6 +103,9 @@ public partial class CreateInstanceViewModel : ObservableObject
OnPropertyChanged(nameof(PreviewVolUserscripts));
OnPropertyChanged(nameof(PreviewVolCaCerts));
OnPropertyChanged(nameof(PreviewSecret));
OnPropertyChanged(nameof(PreviewSecretUser));
OnPropertyChanged(nameof(PreviewSecretHost));
OnPropertyChanged(nameof(PreviewSecretPort));
OnPropertyChanged(nameof(PreviewMySqlDb));
OnPropertyChanged(nameof(PreviewMySqlUser));
OnPropertyChanged(nameof(PreviewCmsUrl));
@@ -106,16 +119,138 @@ public partial class CreateInstanceViewModel : ObservableObject
AvailableHosts = new ObservableCollection<SshHost>(hosts);
}
private async Task LoadCifsDefaultsAsync()
private async Task LoadNfsDefaultsAsync()
{
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
CifsShareName = await settings.GetAsync(SettingsService.CifsShareName) ?? string.Empty;
CifsShareFolder = await settings.GetAsync(SettingsService.CifsShareFolder) ?? string.Empty;
CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty;
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
NfsServer = await settings.GetAsync(SettingsService.NfsServer) ?? string.Empty;
NfsExport = await settings.GetAsync(SettingsService.NfsExport) ?? string.Empty;
NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder) ?? string.Empty;
NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions) ?? string.Empty;
}
[RelayCommand]
private async Task LoadYmlPreviewAsync()
{
if (!Valid)
{
PreviewYml = "# Abbreviation must be exactly 3 lowercase letters (a-z) before loading the YML preview.";
return;
}
IsLoadingYml = true;
try
{
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
var composer = scope.ServiceProvider.GetRequiredService<ComposeRenderService>();
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
if (string.IsNullOrWhiteSpace(repoUrl))
{
PreviewYml = "# Git template repository URL is not configured. Set it in Settings → Git Repo URL.";
return;
}
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
var abbrev = Abbrev;
var stackName = $"{abbrev}-cms-stack";
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_user")).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 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");
// Use form values; fall back to saved global settings
var nfsServer = string.IsNullOrWhiteSpace(NfsServer) ? await settings.GetAsync(SettingsService.NfsServer) : NfsServer;
var nfsExport = string.IsNullOrWhiteSpace(NfsExport) ? await settings.GetAsync(SettingsService.NfsExport) : NfsExport;
var nfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? await settings.GetAsync(SettingsService.NfsExportFolder) : NfsExportFolder;
var nfsOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? await settings.GetAsync(SettingsService.NfsOptions, string.Empty) : NfsExtraOptions;
var ctx = new RenderContext
{
CustomerName = CustomerName.Trim(),
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,
PangolinEndpoint = pangolinEndpoint,
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
NfsServer = nfsServer,
NfsExport = nfsExport,
NfsExportFolder = nfsExportFolder,
NfsExtraOptions = nfsOptions,
};
PreviewYml = composer.Render(templateConfig.Yaml, ctx);
}
catch (Exception ex)
{
PreviewYml = $"# Error rendering YML preview:\n# {ex.Message}";
}
finally
{
IsLoadingYml = false;
}
}
[RelayCommand]
private async Task CopyYmlAsync()
{
if (string.IsNullOrEmpty(PreviewYml)) return;
var mainWindow = (Application.Current?.ApplicationLifetime
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is null) return;
var clipboard = TopLevel.GetTopLevel(mainWindow)?.Clipboard;
if (clipboard is not null)
await clipboard.SetTextAsync(PreviewYml);
}
[RelayCommand]
@@ -145,7 +280,8 @@ public partial class CreateInstanceViewModel : ObservableObject
try
{
// Wire SSH host into docker services
// Wire SSH host into docker services (singletons must know the target host before
// InstanceService uses them internally for secrets and CLI operations)
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedSshHost);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
@@ -154,38 +290,12 @@ public partial class CreateInstanceViewModel : ObservableObject
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 direct TCP ────────
SetProgress(35, "Creating MySQL database and user...");
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
Abbrev,
mysqlPassword);
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...");
// InstanceService.CreateInstanceAsync handles the full provisioning flow:
// 1. Clone template repo
// 2. Generate MySQL password → create Docker Swarm secret
// 3. Create MySQL database + SQL user (same password as the secret)
// 4. Render compose YAML → deploy stack
SetProgress(30, "Provisioning instance (MySQL user, secrets, stack)...");
var dto = new CreateInstanceDto
{
@@ -194,12 +304,10 @@ public partial class CreateInstanceViewModel : ObservableObject
SshHostId = SelectedSshHost.Id,
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
CifsShareName = string.IsNullOrWhiteSpace(CifsShareName) ? null : CifsShareName.Trim(),
CifsShareFolder = string.IsNullOrWhiteSpace(CifsShareFolder) ? null : CifsShareFolder.Trim(),
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(),
NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(),
NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(),
NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(),
};
var result = await instanceSvc.CreateInstanceAsync(dto);
@@ -236,10 +344,5 @@ public partial class CreateInstanceViewModel : ObservableObject
DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text;
}
private static string GenerateRandomPassword(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
return RandomNumberGenerator.GetString(chars, length);
}
}