From 610cdb62a862e0b8712a49a9bb2abfef791a5a13 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 17 Dec 2025 15:34:34 -0700 Subject: [PATCH] begin application build overhaul --- App.axaml | 12 + App.axaml.cs | 30 ++ Models/ConfigRoot.cs | 15 + Models/GlobalSettings.cs | 16 ++ Models/SshConfig.cs | 17 ++ Models/WorkerConfig.cs | 23 ++ Models/WorkerTypeConfig.cs | 48 ++++ Program.cs | 19 ++ Scripts/remote_worker_attach.ps1 | 116 ++++++++ Scripts/remote_worker_controller.ps1 | 407 +++++++++++++++++++++++++++ Services/AttachService.cs | 41 +++ Services/ConfigService.cs | 128 +++++++++ Services/SshService.cs | 307 ++++++++++++++++++++ Services/WorkerControllerService.cs | 344 ++++++++++++++++++++++ UnifiedFarmLauncher.csproj | 30 ++ ViewModels/MainWindowViewModel.cs | 99 +++++++ ViewModels/ViewModelBase.cs | 26 ++ ViewModels/WorkerEditViewModel.cs | 204 ++++++++++++++ Views/MainWindow.axaml | 53 ++++ Views/MainWindow.axaml.cs | 211 ++++++++++++++ Views/WorkerEditWindow.axaml | 90 ++++++ Views/WorkerEditWindow.axaml.cs | 131 +++++++++ app.manifest | 12 + 23 files changed, 2379 insertions(+) create mode 100644 App.axaml create mode 100644 App.axaml.cs create mode 100644 Models/ConfigRoot.cs create mode 100644 Models/GlobalSettings.cs create mode 100644 Models/SshConfig.cs create mode 100644 Models/WorkerConfig.cs create mode 100644 Models/WorkerTypeConfig.cs create mode 100644 Program.cs create mode 100644 Scripts/remote_worker_attach.ps1 create mode 100644 Scripts/remote_worker_controller.ps1 create mode 100644 Services/AttachService.cs create mode 100644 Services/ConfigService.cs create mode 100644 Services/SshService.cs create mode 100644 Services/WorkerControllerService.cs create mode 100644 UnifiedFarmLauncher.csproj create mode 100644 ViewModels/MainWindowViewModel.cs create mode 100644 ViewModels/ViewModelBase.cs create mode 100644 ViewModels/WorkerEditViewModel.cs create mode 100644 Views/MainWindow.axaml create mode 100644 Views/MainWindow.axaml.cs create mode 100644 Views/WorkerEditWindow.axaml create mode 100644 Views/WorkerEditWindow.axaml.cs create mode 100644 app.manifest diff --git a/App.axaml b/App.axaml new file mode 100644 index 0000000..54fb39a --- /dev/null +++ b/App.axaml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/App.axaml.cs b/App.axaml.cs new file mode 100644 index 0000000..7cc1602 --- /dev/null +++ b/App.axaml.cs @@ -0,0 +1,30 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using UnifiedFarmLauncher.ViewModels; +using UnifiedFarmLauncher.Views; + +namespace UnifiedFarmLauncher +{ + public partial class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } + } +} + diff --git a/Models/ConfigRoot.cs b/Models/ConfigRoot.cs new file mode 100644 index 0000000..c4eafe5 --- /dev/null +++ b/Models/ConfigRoot.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace UnifiedFarmLauncher.Models +{ + public class ConfigRoot + { + [JsonPropertyName("workers")] + public List Workers { get; set; } = new(); + + [JsonPropertyName("globalSettings")] + public GlobalSettings GlobalSettings { get; set; } = new(); + } +} + diff --git a/Models/GlobalSettings.cs b/Models/GlobalSettings.cs new file mode 100644 index 0000000..52459fa --- /dev/null +++ b/Models/GlobalSettings.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace UnifiedFarmLauncher.Models +{ + public class GlobalSettings + { + [JsonPropertyName("sheepitJarUrls")] + public List SheepItJarUrls { get; set; } = new() + { + "https://www.sheepit-renderfarm.com/media/applet/client-latest.php", + "https://www.sheepit-renderfarm.com/media/applet/client-latest.jar" + }; + } +} + diff --git a/Models/SshConfig.cs b/Models/SshConfig.cs new file mode 100644 index 0000000..dc1465f --- /dev/null +++ b/Models/SshConfig.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace UnifiedFarmLauncher.Models +{ + public class SshConfig + { + [JsonPropertyName("host")] + public string Host { get; set; } = string.Empty; + + [JsonPropertyName("port")] + public int Port { get; set; } = 22; + + [JsonPropertyName("args")] + public string Args { get; set; } = string.Empty; + } +} + diff --git a/Models/WorkerConfig.cs b/Models/WorkerConfig.cs new file mode 100644 index 0000000..697c7f3 --- /dev/null +++ b/Models/WorkerConfig.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace UnifiedFarmLauncher.Models +{ + public class WorkerConfig + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("ssh")] + public SshConfig Ssh { get; set; } = new(); + + [JsonPropertyName("workerTypes")] + public WorkerTypeConfig WorkerTypes { get; set; } = new(); + } +} + diff --git a/Models/WorkerTypeConfig.cs b/Models/WorkerTypeConfig.cs new file mode 100644 index 0000000..cdb4238 --- /dev/null +++ b/Models/WorkerTypeConfig.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace UnifiedFarmLauncher.Models +{ + public class SheepItConfig + { + [JsonPropertyName("gpu")] + public string Gpu { get; set; } = string.Empty; + + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("renderKey")] + public string RenderKey { get; set; } = string.Empty; + } + + public class FlamencoConfig + { + [JsonPropertyName("workerPath")] + public string WorkerPath { get; set; } = string.Empty; + + [JsonPropertyName("networkDrives")] + public List NetworkDrives { get; set; } = new(); + + [JsonPropertyName("networkPaths")] + public List NetworkPaths { get; set; } = new(); + } + + public class WorkerTypeConfig + { + [JsonPropertyName("sheepit")] + public SheepItConfig? SheepIt { get; set; } + + [JsonPropertyName("flamenco")] + public FlamencoConfig? Flamenco { get; set; } + + public override string ToString() + { + var types = new List(); + if (SheepIt != null) types.Add("SheepIt"); + if (Flamenco != null) types.Add("Flamenco"); + return string.Join(", ", types); + } + } +} + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..7ab65b0 --- /dev/null +++ b/Program.cs @@ -0,0 +1,19 @@ +using Avalonia; +using System; + +namespace UnifiedFarmLauncher +{ + internal class Program + { + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + } +} + diff --git a/Scripts/remote_worker_attach.ps1 b/Scripts/remote_worker_attach.ps1 new file mode 100644 index 0000000..90e1c7e --- /dev/null +++ b/Scripts/remote_worker_attach.ps1 @@ -0,0 +1,116 @@ +param( + [Parameter(Mandatory = $true)] + [string]$WorkerName, + + [Parameter(Mandatory = $true)] + [string]$WorkerType, + +[string]$DataRoot = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'), + + [switch]$CommandOnly, + + [string]$Command +) + +$ErrorActionPreference = 'Stop' + +function Get-WorkerPaths { + param([string]$Root, [string]$Type, [string]$Name) + + $instanceRoot = Join-Path -Path (Join-Path -Path $Root -ChildPath $Type) -ChildPath $Name + return [pscustomobject]@{ + Metadata = Join-Path -Path $instanceRoot -ChildPath 'state\worker-info.json' + Command = Join-Path -Path $instanceRoot -ChildPath 'state\commands.txt' + Log = Join-Path -Path $instanceRoot -ChildPath 'logs\worker.log' + } +} + +$paths = Get-WorkerPaths -Root $DataRoot -Type $WorkerType -Name $WorkerName + +if (-not (Test-Path $paths.Metadata)) { + Write-Host "No worker metadata found for $WorkerName ($WorkerType)." -ForegroundColor Red + exit 1 +} + +try { + $metadata = Get-Content -Path $paths.Metadata -Raw | ConvertFrom-Json +} +catch { + Write-Host "Unable to read worker metadata: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} + +if (Test-Path $paths.Log) { + # ensure log file exists but do not truncate + $null = (Get-Item $paths.Log) +} else { + New-Item -Path $paths.Log -ItemType File -Force | Out-Null +} + +if (-not (Test-Path $paths.Command)) { + New-Item -Path $paths.Command -ItemType File -Force | Out-Null +} + +function Send-WorkerCommand { + param([string]$Value) + + $clean = $Value.Trim() + if (-not $clean) { + return + } + + Add-Content -Path $paths.Command -Value $clean -Encoding UTF8 + Write-Host "Sent command '$clean' to $WorkerName." -ForegroundColor DarkGray +} + +if ($CommandOnly) { + if (-not $Command) { + Write-Host "CommandOnly flag set but no command provided." -ForegroundColor Red + exit 1 + } + + Send-WorkerCommand -Value $Command + exit 0 +} + +Write-Host "Attaching to $WorkerName ($WorkerType) logs." -ForegroundColor Cyan +Write-Host "Type commands and press Enter. Type 'detach' to exit session." -ForegroundColor Yellow + +$logJob = Start-Job -ScriptBlock { + param($LogPath) + Get-Content -Path $LogPath -Tail 50 -Wait +} -ArgumentList $paths.Log + +try { + while ($true) { + $input = Read-Host "> " + if ($null -eq $input) { + continue + } + + $normalized = $input.Trim() + if ($normalized.StartsWith(':')) { + $normalized = $normalized.TrimStart(':').Trim() + } + + if ($normalized.Length -eq 0) { + continue + } + + if ($normalized.ToLowerInvariant() -eq 'detach') { + break + } + + Send-WorkerCommand -Value $input + } +} +finally { + if ($logJob) { + Stop-Job -Job $logJob -ErrorAction SilentlyContinue + Receive-Job -Job $logJob -ErrorAction SilentlyContinue | Out-Null + Remove-Job -Job $logJob -ErrorAction SilentlyContinue + } + + Write-Host "Detached from worker $WorkerName." -ForegroundColor Cyan +} + diff --git a/Scripts/remote_worker_controller.ps1 b/Scripts/remote_worker_controller.ps1 new file mode 100644 index 0000000..df86fce --- /dev/null +++ b/Scripts/remote_worker_controller.ps1 @@ -0,0 +1,407 @@ +param( + [Parameter(Mandatory = $true)] + [string]$WorkerName, + + [Parameter(Mandatory = $true)] + [string]$WorkerType, + + [string]$PayloadBase64, + + [string]$PayloadBase64Path, + +[string]$DataRoot = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'), + + [int]$MaxRestarts = 5, + + [int]$RestartDelaySeconds = 10 +) + +$ErrorActionPreference = 'Stop' +try { + if ($Host -and $Host.Runspace) { + [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace = $Host.Runspace + } +} +catch { + # Ignore runspace assignment errors - not critical for non-interactive execution +} + +# region Path setup +$workerRoot = Join-Path -Path $DataRoot -ChildPath $WorkerType +$instanceRoot = Join-Path -Path $workerRoot -ChildPath $WorkerName +New-Item -ItemType Directory -Path $instanceRoot -Force | Out-Null + +$logsRoot = Join-Path -Path $instanceRoot -ChildPath 'logs' +$stateRoot = Join-Path -Path $instanceRoot -ChildPath 'state' +New-Item -ItemType Directory -Path $logsRoot -Force | Out-Null +New-Item -ItemType Directory -Path $stateRoot -Force | Out-Null + +$logPath = Join-Path -Path $logsRoot -ChildPath 'worker.log' +$metaPath = Join-Path -Path $stateRoot -ChildPath 'worker-info.json' +$commandPath = Join-Path -Path $stateRoot -ChildPath 'commands.txt' +$payloadPath = Join-Path -Path $stateRoot -ChildPath "payload.ps1" +# endregion + +# region Logging +try { + $logStream = [System.IO.FileStream]::new( + $logPath, + [System.IO.FileMode]::Append, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::ReadWrite + ) + $logWriter = [System.IO.StreamWriter]::new($logStream, [System.Text.Encoding]::UTF8) + $logWriter.AutoFlush = $true +} +catch { + # If we can't open the log file, write error to metadata and exit + $errorMeta = [pscustomobject]@{ + WorkerName = $WorkerName + WorkerType = $WorkerType + Status = 'error' + ControllerPid = $PID + WorkerPid = $null + Restarts = 0 + LastExitCode = 1 + LogPath = $logPath + CommandPath = $commandPath + PayloadPath = $payloadPath + UpdatedAtUtc = (Get-Date).ToUniversalTime() + ErrorMessage = "Failed to open log file: $($_.Exception.Message)" + } + $errorMeta | ConvertTo-Json -Depth 5 | Set-Content -Path $metaPath -Encoding UTF8 -ErrorAction SilentlyContinue + Write-Error "Controller failed to initialize: $($_.Exception.Message)" + exit 1 +} + +# Create C# event handler class that doesn't require PowerShell runspace +if (-not ("UnifiedWorkers.ProcessLogHandler" -as [type])) { + $csharpCode = @' +using System; +using System.Diagnostics; +using System.IO; + +namespace UnifiedWorkers +{ + public sealed class ProcessLogHandler + { + private readonly TextWriter _writer; + private readonly string _prefix; + private readonly object _lock = new object(); + + public ProcessLogHandler(TextWriter writer, string prefix) + { + _writer = writer ?? throw new ArgumentNullException("writer"); + _prefix = prefix ?? throw new ArgumentNullException("prefix"); + } + + public void OnDataReceived(object sender, DataReceivedEventArgs e) + { + if (e == null || string.IsNullOrEmpty(e.Data)) + { + return; + } + + lock (_lock) + { + try + { + var timestamp = DateTime.UtcNow.ToString("u"); + _writer.WriteLine(string.Format("[{0} {1}] {2}", _prefix, timestamp, e.Data)); + _writer.Flush(); + } + catch + { + // Ignore write errors to prevent cascading failures + } + } + } + } +} +'@ + Add-Type -TypeDefinition $csharpCode -ErrorAction Stop +} + +function Write-LogLine { + param( + [string]$Prefix, + [string]$Message + ) + + if (-not $logWriter) { return } + $timestamp = (Get-Date).ToString('u') + $logWriter.WriteLine("[$Prefix $timestamp] $Message") +} + +function Write-ControllerLog { + param([string]$Message) + Write-LogLine -Prefix 'CTRL' -Message $Message +} + +function Write-FatalLog { + param([string]$Message) + + try { + Write-ControllerLog $Message + } + catch { + $timestamp = (Get-Date).ToString('u') + $fallback = "[CTRL $timestamp] $Message" + try { + [System.IO.File]::AppendAllText($logPath, $fallback + [Environment]::NewLine, [System.Text.Encoding]::UTF8) + } + catch { + # last resort: write to host + Write-Error $fallback + } + } +} +# endregion + +# region Helpers + +function Resolve-PayloadBase64 { + if ($PayloadBase64) { + return $PayloadBase64.Trim() + } + + if ($PayloadBase64Path) { + if (-not (Test-Path $PayloadBase64Path)) { + throw "Payload file '$PayloadBase64Path' not found." + } + + $content = Get-Content -Path $PayloadBase64Path -Raw + if ([string]::IsNullOrWhiteSpace($content)) { + throw "Payload file '$PayloadBase64Path' is empty." + } + + return $content.Trim() + } + + throw "No payload data provided to controller." +} + +function Write-Metadata { + param( + [string]$Status, + [nullable[int]]$WorkerPid = $null, + [nullable[int]]$ControllerPid = $PID, + [int]$Restarts = 0, + [nullable[int]]$LastExitCode = $null + ) + + $payload = [pscustomobject]@{ + WorkerName = $WorkerName + WorkerType = $WorkerType + Status = $Status + ControllerPid = $ControllerPid + WorkerPid = $WorkerPid + Restarts = $Restarts + LastExitCode = $LastExitCode + LogPath = $logPath + CommandPath = $commandPath + PayloadPath = $payloadPath + UpdatedAtUtc = (Get-Date).ToUniversalTime() + } + + $payload | ConvertTo-Json -Depth 5 | Set-Content -Path $metaPath -Encoding UTF8 +} + +function Get-PendingCommands { + if (-not (Test-Path $commandPath)) { + return @() + } + + try { + $lines = Get-Content -Path $commandPath -ErrorAction Stop + Remove-Item -Path $commandPath -Force -ErrorAction SilentlyContinue + return $lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } + catch { + return @() + } +} +# endregion + +try { + # record initial state before launching worker + Write-Metadata -Status 'initializing' -WorkerPid $null -ControllerPid $PID -Restarts 0 + + $resolvedPayloadBase64 = Resolve-PayloadBase64 + $PayloadBase64 = $resolvedPayloadBase64 + + try { + # Write payload script to disk + # The payload is base64-encoded UTF-16 (Unicode), so decode it properly + Write-ControllerLog "Decoding payload base64 (length: $($resolvedPayloadBase64.Length))" + $payloadBytes = [Convert]::FromBase64String($resolvedPayloadBase64) + Write-ControllerLog "Decoded payload to $($payloadBytes.Length) bytes" + + # Convert UTF-16 bytes back to string, then write as UTF-8 (PowerShell's preferred encoding) + $payloadText = [Text.Encoding]::Unicode.GetString($payloadBytes) + [IO.File]::WriteAllText($payloadPath, $payloadText, [Text.Encoding]::UTF8) + Write-ControllerLog "Payload written to $payloadPath ($($payloadText.Length) characters)" + } + catch { + Write-FatalLog "Unable to write payload: $($_.Exception.Message)" + if ($_.Exception.InnerException) { + Write-FatalLog "Inner exception: $($_.Exception.InnerException.Message)" + } + throw + } + + $restartCount = 0 + $controllerPid = $PID + + while ($restartCount -le $MaxRestarts) { + try { + # Initialize worker process + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $pwsh = Get-Command pwsh -ErrorAction SilentlyContinue + if ($pwsh) { + $psi.FileName = $pwsh.Source + } + else { + $psi.FileName = (Get-Command powershell -ErrorAction Stop).Source + } + + $psi.Arguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$payloadPath`"" + $psi.UseShellExecute = $false + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + + $workerProcess = New-Object System.Diagnostics.Process + $workerProcess.StartInfo = $psi + + if (-not $workerProcess.Start()) { + throw "Failed to start worker process." + } + + Write-ControllerLog "Worker process started with PID $($workerProcess.Id)" + Write-Metadata -Status 'running' -WorkerPid $workerProcess.Id -ControllerPid $controllerPid -Restarts $restartCount + + # Check if process exited immediately + if ($workerProcess.HasExited) { + $exitCode = -1 + try { + $exitCode = $workerProcess.ExitCode + } + catch { + Write-ControllerLog "Unable to read immediate exit code: $($_.Exception.Message)" + } + Write-ControllerLog "Worker process exited immediately after start with code $exitCode" + Write-Metadata -Status 'stopped' -WorkerPid $null -ControllerPid $controllerPid -Restarts $restartCount -LastExitCode $exitCode + if ($exitCode -eq 0) { break } + # Continue to restart logic below + } + else { + $stdoutHandler = [UnifiedWorkers.ProcessLogHandler]::new($logWriter, 'OUT') + $stderrHandler = [UnifiedWorkers.ProcessLogHandler]::new($logWriter, 'ERR') + + $outputHandler = [System.Diagnostics.DataReceivedEventHandler]$stdoutHandler.OnDataReceived + $errorHandler = [System.Diagnostics.DataReceivedEventHandler]$stderrHandler.OnDataReceived + + $workerProcess.add_OutputDataReceived($outputHandler) + $workerProcess.add_ErrorDataReceived($errorHandler) + $workerProcess.BeginOutputReadLine() + $workerProcess.BeginErrorReadLine() + Write-ControllerLog "Output handlers set up successfully" + + # Give process a moment to start, then check again + Start-Sleep -Milliseconds 200 + if ($workerProcess.HasExited) { + $exitCode = -1 + try { + $exitCode = $workerProcess.ExitCode + } + catch { + Write-ControllerLog "Unable to read exit code after delay: $($_.Exception.Message)" + } + Write-ControllerLog "Worker process exited after 200ms delay with code $exitCode" + Write-Metadata -Status 'stopped' -WorkerPid $null -ControllerPid $controllerPid -Restarts $restartCount -LastExitCode $exitCode + if ($exitCode -eq 0) { break } + # Continue to restart logic below + } + else { + Write-ControllerLog "Worker process is running, entering monitoring loop" + + while (-not $workerProcess.HasExited) { + $commands = Get-PendingCommands + foreach ($command in $commands) { + $trimmed = $command.Trim() + if (-not $trimmed) { continue } + + Write-ControllerLog "Received command '$trimmed'" + try { + $workerProcess.StandardInput.WriteLine($trimmed) + $workerProcess.StandardInput.Flush() + } + catch { + Write-ControllerLog "Failed to forward command '$trimmed': $($_.Exception.Message)" + } + + if ($trimmed -ieq 'quit') { + Write-ControllerLog "Quit command issued. Waiting for worker to exit." + } + } + + Start-Sleep -Milliseconds 500 + } + # End of monitoring loop - process has exited + Write-ControllerLog "Worker process exited, exiting monitoring loop" + } + } + + # Wait for process to fully exit before reading exit code + $workerProcess.WaitForExit(1000) + + $exitCode = -1 + try { + $exitCode = $workerProcess.ExitCode + } + catch { + Write-ControllerLog "Unable to read worker exit code: $($_.Exception.Message)" + } + + Write-ControllerLog "Worker exited with code $exitCode" + Write-Metadata -Status 'stopped' -WorkerPid $null -ControllerPid $controllerPid -Restarts $restartCount -LastExitCode $exitCode + + if ($exitCode -eq 0) { + break + } + } + catch { + Write-ControllerLog "Controller error: $($_.Exception.Message)" + } + + $restartCount++ + if ($restartCount -gt $MaxRestarts) { + Write-ControllerLog "Maximum restart attempts reached. Controller stopping." + break + } + + Write-ControllerLog "Restarting worker in $RestartDelaySeconds seconds (attempt $restartCount of $MaxRestarts)." + Start-Sleep -Seconds $RestartDelaySeconds + } + + Write-Metadata -Status 'inactive' -WorkerPid $null -ControllerPid $controllerPid -Restarts $restartCount + Write-ControllerLog "Controller exiting." +} +catch { + Write-FatalLog "Fatal controller error: $($_.Exception.Message)" + if ($_.ScriptStackTrace) { + Write-FatalLog "Stack: $($_.ScriptStackTrace)" + } + throw +} +finally { + if ($logWriter) { + $logWriter.Dispose() + } + if ($logStream) { + $logStream.Dispose() + } +} + diff --git a/Services/AttachService.cs b/Services/AttachService.cs new file mode 100644 index 0000000..b648cd6 --- /dev/null +++ b/Services/AttachService.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; +using System.Threading.Tasks; +using UnifiedFarmLauncher.Models; + +namespace UnifiedFarmLauncher.Services +{ + public class AttachService + { + private readonly SshService _sshService; + private readonly WorkerControllerService _controllerService; + + public AttachService(SshService sshService, WorkerControllerService controllerService) + { + _sshService = sshService; + _controllerService = controllerService; + } + + public async Task AttachToWorkerAsync(WorkerConfig worker, string workerType, bool commandOnly = false, string? command = null) + { + await _controllerService.DeployAttachHelperAsync(worker); + + var remoteBasePath = await _sshService.GetWorkerBasePathAsync(worker); + var remoteHelper = $"{remoteBasePath.Replace("\\", "/")}/attach-helper.ps1"; + + var paramsBlock = $"-WorkerName \"{worker.Name}\" -WorkerType \"{workerType}\""; + if (commandOnly) + { + paramsBlock += " -CommandOnly"; + } + if (!string.IsNullOrEmpty(command)) + { + paramsBlock += $" -Command \"{command}\""; + } + + var remoteCmd = $"powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File \"{remoteHelper}\" {paramsBlock}"; + + _sshService.StartInteractiveSsh(worker, remoteCmd); + } + } +} + diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs new file mode 100644 index 0000000..6d121c7 --- /dev/null +++ b/Services/ConfigService.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using UnifiedFarmLauncher.Models; + +namespace UnifiedFarmLauncher.Services +{ + public class ConfigService + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private readonly string _configPath; + private ConfigRoot? _config; + + public ConfigService() + { + var appDataPath = GetAppDataPath(); + Directory.CreateDirectory(appDataPath); + _configPath = Path.Combine(appDataPath, "workers.json"); + } + + private static string GetAppDataPath() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(localAppData, "UnifiedFarmLauncher"); + } + + public ConfigRoot Load() + { + if (_config != null) + return _config; + + if (!File.Exists(_configPath)) + { + _config = new ConfigRoot(); + Save(_config); + return _config; + } + + try + { + var json = File.ReadAllText(_configPath); + _config = JsonSerializer.Deserialize(json, JsonOptions) ?? new ConfigRoot(); + return _config; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to load configuration from {_configPath}: {ex.Message}", ex); + } + } + + public void Save(ConfigRoot? config = null) + { + config ??= _config ?? new ConfigRoot(); + _config = config; + + try + { + var json = JsonSerializer.Serialize(config, JsonOptions); + File.WriteAllText(_configPath, json); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to save configuration to {_configPath}: {ex.Message}", ex); + } + } + + public void Reload() + { + _config = null; + Load(); + } + + public WorkerConfig? GetWorker(int id) + { + return Load().Workers.FirstOrDefault(w => w.Id == id); + } + + public WorkerConfig? GetWorkerByName(string name) + { + return Load().Workers.FirstOrDefault(w => w.Name == name); + } + + public void AddWorker(WorkerConfig worker) + { + var config = Load(); + if (config.Workers.Any(w => w.Id == worker.Id || w.Name == worker.Name)) + throw new InvalidOperationException($"Worker with ID {worker.Id} or name '{worker.Name}' already exists"); + + config.Workers.Add(worker); + Save(config); + } + + public void UpdateWorker(WorkerConfig worker) + { + var config = Load(); + var index = config.Workers.FindIndex(w => w.Id == worker.Id); + if (index < 0) + throw new InvalidOperationException($"Worker with ID {worker.Id} not found"); + + config.Workers[index] = worker; + Save(config); + } + + public void DeleteWorker(int id) + { + var config = Load(); + var worker = config.Workers.FirstOrDefault(w => w.Id == id); + if (worker == null) + throw new InvalidOperationException($"Worker with ID {id} not found"); + + config.Workers.Remove(worker); + Save(config); + } + + public int GetNextWorkerId() + { + var config = Load(); + return config.Workers.Count > 0 ? config.Workers.Max(w => w.Id) + 1 : 1; + } + } +} + diff --git a/Services/SshService.cs b/Services/SshService.cs new file mode 100644 index 0000000..1a32c2b --- /dev/null +++ b/Services/SshService.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using UnifiedFarmLauncher.Models; + +namespace UnifiedFarmLauncher.Services +{ + public class SshConnectionParts + { + public string Host { get; set; } = string.Empty; + public List Options { get; set; } = new(); + public int? Port { get; set; } + public bool RequestPty { get; set; } + } + + public class SshService + { + private readonly Dictionary _workerBasePathCache = new(); + + private static string GetSshExecutable() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Try common Windows OpenSSH locations + var paths = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "OpenSSH", "ssh.exe"), + "ssh.exe" // In PATH + }; + foreach (var path in paths) + { + if (File.Exists(path) || IsInPath("ssh.exe")) + return "ssh.exe"; + } + return "ssh.exe"; + } + return "ssh"; + } + + private static string GetScpExecutable() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var paths = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "OpenSSH", "scp.exe"), + "scp.exe" + }; + foreach (var path in paths) + { + if (File.Exists(path) || IsInPath("scp.exe")) + return "scp.exe"; + } + return "scp.exe"; + } + return "scp"; + } + + private static bool IsInPath(string executable) + { + try + { + var process = Process.Start(new ProcessStartInfo + { + FileName = executable, + Arguments = "-V", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }); + process?.WaitForExit(1000); + return process?.ExitCode == 0 || process?.ExitCode == 1; // SSH/SCP typically exit with 1 for version info + } + catch + { + return false; + } + } + + public SshConnectionParts ParseConnectionParts(string rawArgs, string defaultHost) + { + var parts = new SshConnectionParts { Host = defaultHost }; + if (string.IsNullOrWhiteSpace(rawArgs)) + return parts; + + var tokens = rawArgs.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + var options = new List(); + string? targetHost = null; + int? port = null; + bool requestPty = false; + + var optionsWithArgs = new HashSet { "-i", "-o", "-c", "-D", "-E", "-F", "-I", "-J", "-L", "-l", "-m", "-O", "-Q", "-R", "-S", "-W", "-w" }; + + for (int i = 0; i < tokens.Length; i++) + { + var token = tokens[i]; + if (token == "-t" || token == "-tt") + { + requestPty = true; + continue; + } + + if (token == "-p" && i + 1 < tokens.Length) + { + if (int.TryParse(tokens[i + 1], out var portValue)) + { + port = portValue; + i++; + } + continue; + } + + if (token.StartsWith("-")) + { + options.Add(token); + if (optionsWithArgs.Contains(token) && i + 1 < tokens.Length) + { + options.Add(tokens[i + 1]); + i++; + } + continue; + } + + if (targetHost == null) + { + targetHost = token; + continue; + } + + options.Add(token); + } + + parts.Host = targetHost ?? defaultHost; + parts.Options = options; + parts.Port = port; + parts.RequestPty = requestPty; + return parts; + } + + public List BuildSshArgs(SshConnectionParts parts, bool interactive) + { + var args = new List + { + "-o", "ServerAliveInterval=60", + "-o", "ServerAliveCountMax=30" + }; + + if (interactive && parts.RequestPty) + { + args.Add("-t"); + } + else if (!interactive) + { + args.Add("-T"); + } + + args.AddRange(parts.Options); + + if (parts.Port.HasValue) + { + args.Add("-p"); + args.Add(parts.Port.Value.ToString()); + } + + args.Add(parts.Host); + return args; + } + + public List BuildScpArgs(SshConnectionParts parts) + { + var args = new List + { + "-o", "ServerAliveInterval=60", + "-o", "ServerAliveCountMax=30" + }; + + args.AddRange(parts.Options); + + if (parts.Port.HasValue) + { + args.Add("-P"); + args.Add(parts.Port.Value.ToString()); + } + + return args; + } + + public async Task ExecuteRemoteCommandAsync(WorkerConfig worker, string command, bool interactive = false) + { + var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host); + var sshArgs = BuildSshArgs(parts, interactive); + sshArgs.Add(command); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = GetSshExecutable(), + Arguments = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = !interactive + } + }; + + var output = new StringBuilder(); + var error = new StringBuilder(); + + process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); }; + process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(); + + if (process.ExitCode != 0 && !interactive) + { + throw new InvalidOperationException($"SSH command failed with exit code {process.ExitCode}: {error}"); + } + + return output.ToString(); + } + + public async Task GetWorkerBasePathAsync(WorkerConfig worker) + { + if (_workerBasePathCache.TryGetValue(worker.Name, out var cached)) + return cached; + + var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host); + var scriptBlock = "$ProgressPreference='SilentlyContinue'; [Environment]::GetFolderPath('LocalApplicationData')"; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(scriptBlock)); + var remoteCmd = $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {encoded}"; + + var output = await ExecuteRemoteCommandAsync(worker, remoteCmd); + var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + var basePath = lines.LastOrDefault()?.Trim(); + + if (string.IsNullOrEmpty(basePath)) + throw new InvalidOperationException($"Unable to read LocalAppData path on {worker.Name}."); + + var finalPath = Path.Combine(basePath, "UnifiedWorkers"); + _workerBasePathCache[worker.Name] = finalPath; + return finalPath; + } + + public async Task CopyFileToRemoteAsync(WorkerConfig worker, string localPath, string remotePath) + { + var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host); + var scpArgs = BuildScpArgs(parts); + scpArgs.Add(localPath); + scpArgs.Add($"{parts.Host}:\"{remotePath.Replace("\\", "/")}\""); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = GetScpExecutable(), + Arguments = string.Join(" ", scpArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(); + throw new InvalidOperationException($"SCP failed with exit code {process.ExitCode}: {error}"); + } + } + + public Process StartInteractiveSsh(WorkerConfig worker, string command) + { + var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host); + var sshArgs = BuildSshArgs(parts, true); + sshArgs.Add(command); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = GetSshExecutable(), + Arguments = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")), + UseShellExecute = true, + CreateNoWindow = false + } + }; + + process.Start(); + return process; + } + } +} + diff --git a/Services/WorkerControllerService.cs b/Services/WorkerControllerService.cs new file mode 100644 index 0000000..0542370 --- /dev/null +++ b/Services/WorkerControllerService.cs @@ -0,0 +1,344 @@ +using System; +using System.IO; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using UnifiedFarmLauncher.Models; + +namespace UnifiedFarmLauncher.Services +{ + public class WorkerControllerService + { + private readonly SshService _sshService; + private readonly ConfigService _configService; + private byte[]? _controllerScriptBytes; + private byte[]? _attachHelperScriptBytes; + + public WorkerControllerService(SshService sshService, ConfigService configService) + { + _sshService = sshService; + _configService = configService; + } + + private byte[] GetControllerScriptBytes() + { + if (_controllerScriptBytes != null) + return _controllerScriptBytes; + + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = "UnifiedFarmLauncher.Scripts.remote_worker_controller.ps1"; + + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + throw new InvalidOperationException($"Resource {resourceName} not found"); + + using var reader = new BinaryReader(stream); + _controllerScriptBytes = reader.ReadBytes((int)stream.Length); + return _controllerScriptBytes; + } + + private byte[] GetAttachHelperScriptBytes() + { + if (_attachHelperScriptBytes != null) + return _attachHelperScriptBytes; + + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = "UnifiedFarmLauncher.Scripts.remote_worker_attach.ps1"; + + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + throw new InvalidOperationException($"Resource {resourceName} not found"); + + using var reader = new BinaryReader(stream); + _attachHelperScriptBytes = reader.ReadBytes((int)stream.Length); + return _attachHelperScriptBytes; + } + + public async Task DeployControllerAsync(WorkerConfig worker) + { + var controllerBase64 = Convert.ToBase64String(GetControllerScriptBytes()); + var script = $@" +$ProgressPreference = 'SilentlyContinue' +$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers' +New-Item -ItemType Directory -Path $dataRoot -Force | Out-Null +$controllerPath = Join-Path $dataRoot 'controller.ps1' +[IO.File]::WriteAllBytes($controllerPath, [Convert]::FromBase64String('{controllerBase64}')) +"; + + await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(script))}"); + } + + public async Task DeployAttachHelperAsync(WorkerConfig worker) + { + var helperBase64 = Convert.ToBase64String(GetAttachHelperScriptBytes()); + var script = $@" +$ProgressPreference = 'SilentlyContinue' +$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers' +New-Item -ItemType Directory -Path $dataRoot -Force | Out-Null +$attachPath = Join-Path $dataRoot 'attach-helper.ps1' +[IO.File]::WriteAllBytes($attachPath, [Convert]::FromBase64String('{helperBase64}')) +"; + + await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(script))}"); + } + + public string GenerateSheepItPayload(WorkerConfig worker) + { + if (worker.WorkerTypes.SheepIt == null) + throw new InvalidOperationException("Worker does not have SheepIt configuration"); + + var config = _configService.Load(); + var sheepIt = worker.WorkerTypes.SheepIt; + var safeKey = sheepIt.RenderKey.Replace("'", "''"); + var safeUser = sheepIt.Username.Replace("'", "''"); + var urls = config.GlobalSettings.SheepItJarUrls; + var urlLiteral = "@(" + string.Join(", ", Array.ConvertAll(urls.ToArray(), url => $"'{url}'")) + ")"; + + return $@" +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +# Write startup message to stderr so controller can capture it +[Console]::Error.WriteLine('[SHEEPIT] Payload script starting...') + +try {{ + $appData = [Environment]::GetFolderPath('ApplicationData') + $sheepDir = Join-Path $appData 'sheepit' + if (-not (Test-Path $sheepDir)) {{ + New-Item -Path $sheepDir -ItemType Directory -Force | Out-Null + }} + + $jarPath = Join-Path $sheepDir 'sheepit-client.jar' + $urls = {urlLiteral} + $headers = @{{ 'User-Agent' = 'Mozilla/5.0' }} + + if (Test-Path $jarPath) {{ + Write-Host ""SheepIt client already present at $jarPath. Skipping download."" -ForegroundColor Green + }} + else {{ + $downloaded = $false + + foreach ($url in $urls) {{ + Write-Host ""Downloading SheepIt client from $url..."" -ForegroundColor Cyan + try {{ + Invoke-WebRequest -Uri $url -OutFile $jarPath -UseBasicParsing -Headers $headers + $downloaded = $true + Write-Host ""Download complete."" -ForegroundColor Green + break + }} + catch {{ + Write-Host (""Download failed from {{0}}: {{1}}"" -f $url, $_.Exception.Message) -ForegroundColor Yellow + }} + }} + + if (-not $downloaded) {{ + throw 'Unable to download SheepIt client from any known URL.' + }} + }} + + [Console]::Error.WriteLine('[SHEEPIT] Starting Java with SheepIt client...') + Set-Location $sheepDir + + $javaArgs = @('-XX:+IgnoreUnrecognizedVMOptions', '-jar', $jarPath, + '-ui', 'text', '--log-stdout', '--verbose', + '-gpu', '{sheepIt.Gpu}', '-login', '{safeUser}', '-password', '{safeKey}') + + try {{ + & java @javaArgs + }} + catch {{ + Write-Host ('Java execution error: {{0}}' -f $_.Exception.Message) -ForegroundColor Red + Write-Host ""If the error persists, try reinstalling Java (Temurin 21 recommended)."" -ForegroundColor Yellow + [Console]::Error.WriteLine(""Java execution error: $($_.Exception.Message)"") + throw + }} +}} +catch {{ + $errorMsg = ('Error: {{0}}' -f $_.Exception.Message) + $stackMsg = ('Stack trace: {{0}}' -f $_.ScriptStackTrace) + Write-Host $errorMsg -ForegroundColor Red + Write-Host $stackMsg -ForegroundColor DarkRed + [Console]::Error.WriteLine($errorMsg) + [Console]::Error.WriteLine($stackMsg) + exit 1 +}} +"; + } + + public string GenerateFlamencoPayload(WorkerConfig worker) + { + if (worker.WorkerTypes.Flamenco == null) + throw new InvalidOperationException("Worker does not have Flamenco configuration"); + + var flamenco = worker.WorkerTypes.Flamenco; + var drives = string.Join(", ", Array.ConvertAll(flamenco.NetworkDrives.ToArray(), d => $"'{d}'")); + var paths = string.Join(", ", Array.ConvertAll(flamenco.NetworkPaths.ToArray(), p => $"'{p.Replace("\\", "\\\\")}'")); + + return $@" +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +# Write startup message to stderr so controller can capture it +[Console]::Error.WriteLine('[FLAMENCO] Payload script starting...') + +try {{ + Write-Host ""Setting up network connections..."" -ForegroundColor Cyan + + $drives = @({drives}) + $networkPaths = @({paths}) + + # Disconnect all existing connections + Write-Host ""Disconnecting existing network connections..."" -ForegroundColor Yellow + foreach ($path in $networkPaths) {{ net use $path /delete /y 2>$null }} + foreach ($drive in $drives) {{ net use $drive /delete /y 2>$null }} + Write-Host ""All network connections cleared."" -ForegroundColor Green + + # Connect to network shares (simplified - credentials should be stored securely) + Write-Host ""Establishing network connections..."" -ForegroundColor Cyan + # TODO: Add credential handling for network shares + + # Start worker + Write-Host ""Starting Flamenco worker..."" -ForegroundColor Cyan + Set-Location '{flamenco.WorkerPath}' + if (Test-Path 'flamenco-worker.exe') {{ + Write-Host ""Running flamenco-worker.exe..."" -ForegroundColor Green + $workerProcess = Start-Process -FilePath '.\flamenco-worker.exe' -NoNewWindow -PassThru -Wait + $exitCode = $workerProcess.ExitCode + Write-Host ""Flamenco worker process has terminated with exit code: $exitCode"" -ForegroundColor Yellow + exit $exitCode + }} else {{ + Write-Host ""Error: flamenco-worker.exe not found in {flamenco.WorkerPath}"" -ForegroundColor Red + [Console]::Error.WriteLine(""Error: flamenco-worker.exe not found in {flamenco.WorkerPath}"") + exit 1 + }} +}} +catch {{ + $errorMsg = ('Error: {{0}}' -f $_.Exception.Message) + $stackMsg = ('Stack trace: {{0}}' -f $_.ScriptStackTrace) + Write-Host $errorMsg -ForegroundColor Red + Write-Host $stackMsg -ForegroundColor DarkRed + [Console]::Error.WriteLine($errorMsg) + [Console]::Error.WriteLine($stackMsg) + exit 1 +}} +"; + } + + public async Task StartWorkerAsync(WorkerConfig worker, string workerType) + { + await DeployControllerAsync(worker); + + string payloadScript; + if (workerType == "sheepit") + { + payloadScript = GenerateSheepItPayload(worker); + } + else if (workerType == "flamenco") + { + payloadScript = GenerateFlamencoPayload(worker); + } + else + { + throw new ArgumentException($"Unknown worker type: {workerType}", nameof(workerType)); + } + + var payloadBase64 = Convert.ToBase64String(Encoding.Unicode.GetBytes(payloadScript)); + var remoteBasePath = await _sshService.GetWorkerBasePathAsync(worker); + + var ensureScript = $@" +$ProgressPreference = 'SilentlyContinue' +$params = ConvertFrom-Json ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('{Convert.ToBase64String(Encoding.Unicode.GetBytes($@"{{""WorkerName"":""{worker.Name}"",""WorkerType"":""{workerType}"",""PayloadBase64"":""{payloadBase64}""}}"@))}'))) +$workerName = $params.WorkerName +$workerType = $params.WorkerType +$payloadBase64 = $params.PayloadBase64 +$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers' +$instanceRoot = Join-Path (Join-Path $dataRoot $workerType) $workerName +$logsRoot = Join-Path $instanceRoot 'logs' +$stateRoot = Join-Path $instanceRoot 'state' +New-Item -ItemType Directory -Path $logsRoot -Force | Out-Null +New-Item -ItemType Directory -Path $stateRoot -Force | Out-Null +$logPath = Join-Path $logsRoot 'worker.log' +$commandPath = Join-Path $stateRoot 'commands.txt' +$payloadPath = Join-Path $stateRoot 'payload.ps1' +$payloadBase64Path = Join-Path $stateRoot 'payload.b64' +if (-not (Test-Path $logPath)) {{ New-Item -Path $logPath -ItemType File -Force | Out-Null }} +if (-not (Test-Path $commandPath)) {{ New-Item -Path $commandPath -ItemType File -Force | Out-Null }} +[IO.File]::WriteAllText($payloadBase64Path, $payloadBase64, [System.Text.Encoding]::UTF8) +$metaPath = Join-Path $instanceRoot 'state\worker-info.json' +$controllerPath = Join-Path $dataRoot 'controller.ps1' + +if (-not (Test-Path $controllerPath)) {{ + throw ""Controller missing at $controllerPath"" +}} + +$shouldStart = $true +if (Test-Path $metaPath) {{ + try {{ + $meta = Get-Content $metaPath -Raw | ConvertFrom-Json + if ($meta.Status -eq 'running' -and $meta.WorkerPid) {{ + if (Get-Process -Id $meta.WorkerPid -ErrorAction SilentlyContinue) {{ + Write-Host ""Worker $workerName already running (PID $($meta.WorkerPid))."" + $shouldStart = $false + }} + }} + }} catch {{ + Write-Host ""Failed to read metadata. Controller will restart worker."" -ForegroundColor Yellow + }} +}} + +if ($shouldStart) {{ + $initialMeta = [pscustomobject]@{{ + WorkerName = $workerName + WorkerType = $workerType + Status = 'launching' + ControllerPid = $null + WorkerPid = $null + Restarts = 0 + LastExitCode = $null + LogPath = $logPath + CommandPath = $commandPath + PayloadPath = $payloadPath + UpdatedAtUtc = (Get-Date).ToUniversalTime() + }} | ConvertTo-Json -Depth 5 + $initialMeta | Set-Content -Path $metaPath -Encoding UTF8 + + $pwsh = Get-Command pwsh -ErrorAction SilentlyContinue + if ($pwsh) {{ + $psExe = $pwsh.Source + }} + else {{ + $psExe = (Get-Command powershell -ErrorAction Stop).Source + }} + + $controllerArgs = @( + '-NoLogo','-NoProfile','-ExecutionPolicy','Bypass', + '-File',""$controllerPath"", + '-WorkerName',""$workerName"", + '-WorkerType',""$workerType"", + '-PayloadBase64Path',""$payloadBase64Path"" + ) + + Start-Process -FilePath $psExe -ArgumentList $controllerArgs -WindowStyle Hidden | Out-Null + Write-Host ""Worker $workerName started under controller."" -ForegroundColor Green +}} +"; + + await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(ensureScript))}"); + } + + public async Task StopWorkerAsync(WorkerConfig worker, string workerType) + { + var script = $@" +$ProgressPreference = 'SilentlyContinue' +$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers' +$instanceRoot = Join-Path (Join-Path $dataRoot '{workerType}') '{worker.Name}' +$commandPath = Join-Path $instanceRoot 'state\commands.txt' +[IO.File]::WriteAllText($commandPath, 'quit', [System.Text.Encoding]::UTF8) +Write-Host ""Quit command sent to worker {worker.Name}."" +"; + + await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(script))}"); + } + } +} + diff --git a/UnifiedFarmLauncher.csproj b/UnifiedFarmLauncher.csproj new file mode 100644 index 0000000..6d32a24 --- /dev/null +++ b/UnifiedFarmLauncher.csproj @@ -0,0 +1,30 @@ + + + WinExe + net8.0 + enable + true + app.manifest + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..e4e18d2 --- /dev/null +++ b/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,99 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using UnifiedFarmLauncher.Models; +using UnifiedFarmLauncher.Services; + +namespace UnifiedFarmLauncher.ViewModels +{ + public class MainWindowViewModel : ViewModelBase + { + private readonly ConfigService _configService; + private WorkerConfig? _selectedWorker; + private string _statusText = "Ready"; + private string _selectedWorkerType = "All"; + + public MainWindowViewModel() + { + _configService = new ConfigService(); + Workers = new ObservableCollection(); + LoadWorkers(); + } + + public ObservableCollection Workers { get; } + + public WorkerConfig? SelectedWorker + { + get => _selectedWorker; + set + { + if (SetAndRaise(ref _selectedWorker, value)) + { + UpdateStatusText(); + } + } + } + + public string StatusText + { + get => _statusText; + set => SetAndRaise(ref _statusText, value); + } + + public string SelectedWorkerType + { + get => _selectedWorkerType; + set + { + if (SetAndRaise(ref _selectedWorkerType, value)) + { + LoadWorkers(); + } + } + } + + public void LoadWorkers() + { + var config = _configService.Load(); + Workers.Clear(); + + var workers = config.Workers; + if (SelectedWorkerType != "All") + { + workers = workers.Where(w => + { + if (SelectedWorkerType == "SheepIt") + return w.WorkerTypes.SheepIt != null; + if (SelectedWorkerType == "Flamenco") + return w.WorkerTypes.Flamenco != null; + return true; + }).ToList(); + } + + foreach (var worker in workers) + { + Workers.Add(worker); + } + + UpdateStatusText(); + } + + private void UpdateStatusText() + { + if (SelectedWorker == null) + { + StatusText = $"Total workers: {Workers.Count}"; + } + else + { + StatusText = $"Selected: {SelectedWorker.Name} ({SelectedWorker.Ssh.Host}:{SelectedWorker.Ssh.Port})"; + } + } + + public void RefreshWorkers() + { + LoadWorkers(); + } + } +} + diff --git a/ViewModels/ViewModelBase.cs b/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..b4dc644 --- /dev/null +++ b/ViewModels/ViewModelBase.cs @@ -0,0 +1,26 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace UnifiedFarmLauncher.ViewModels +{ + public class ViewModelBase : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool SetAndRaise(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (Equals(field, value)) + return false; + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} + diff --git a/ViewModels/WorkerEditViewModel.cs b/ViewModels/WorkerEditViewModel.cs new file mode 100644 index 0000000..83557db --- /dev/null +++ b/ViewModels/WorkerEditViewModel.cs @@ -0,0 +1,204 @@ +using System.Collections.ObjectModel; +using System.Linq; +using UnifiedFarmLauncher.Models; +using UnifiedFarmLauncher.Services; + +namespace UnifiedFarmLauncher.ViewModels +{ + public class WorkerEditViewModel : ViewModelBase + { + private readonly ConfigService _configService; + private readonly bool _isNew; + private int _id; + private string _name = string.Empty; + private bool _enabled = true; + private string _sshHost = string.Empty; + private int _sshPort = 22; + private string _sshArgs = string.Empty; + private string _sheepItGpu = "OPTIX_0"; + private string _sheepItUsername = string.Empty; + private string _sheepItRenderKey = string.Empty; + private string _flamencoWorkerPath = string.Empty; + private bool _hasSheepIt; + private bool _hasFlamenco; + + public WorkerEditViewModel(ConfigService configService, WorkerConfig? worker = null) + { + _configService = configService; + _isNew = worker == null; + NetworkDrives = new ObservableCollection(); + NetworkPaths = new ObservableCollection(); + + if (worker != null) + { + LoadWorker(worker); + } + else + { + _id = _configService.GetNextWorkerId(); + } + } + + public int Id + { + get => _id; + set => SetAndRaise(ref _id, value); + } + + public string Name + { + get => _name; + set => SetAndRaise(ref _name, value); + } + + public bool Enabled + { + get => _enabled; + set => SetAndRaise(ref _enabled, value); + } + + public string SshHost + { + get => _sshHost; + set => SetAndRaise(ref _sshHost, value); + } + + public int SshPort + { + get => _sshPort; + set => SetAndRaise(ref _sshPort, value); + } + + public string SshArgs + { + get => _sshArgs; + set => SetAndRaise(ref _sshArgs, value); + } + + public bool HasSheepIt + { + get => _hasSheepIt; + set => SetAndRaise(ref _hasSheepIt, value); + } + + public bool HasFlamenco + { + get => _hasFlamenco; + set => SetAndRaise(ref _hasFlamenco, value); + } + + public string SheepItGpu + { + get => _sheepItGpu; + set => SetAndRaise(ref _sheepItGpu, value); + } + + public string SheepItUsername + { + get => _sheepItUsername; + set => SetAndRaise(ref _sheepItUsername, value); + } + + public string SheepItRenderKey + { + get => _sheepItRenderKey; + set => SetAndRaise(ref _sheepItRenderKey, value); + } + + public string FlamencoWorkerPath + { + get => _flamencoWorkerPath; + set => SetAndRaise(ref _flamencoWorkerPath, value); + } + + public ObservableCollection NetworkDrives { get; } + public ObservableCollection NetworkPaths { get; } + + private void LoadWorker(WorkerConfig worker) + { + Id = worker.Id; + Name = worker.Name; + Enabled = worker.Enabled; + SshHost = worker.Ssh.Host; + SshPort = worker.Ssh.Port; + SshArgs = worker.Ssh.Args; + + if (worker.WorkerTypes.SheepIt != null) + { + HasSheepIt = true; + SheepItGpu = worker.WorkerTypes.SheepIt.Gpu; + SheepItUsername = worker.WorkerTypes.SheepIt.Username; + SheepItRenderKey = worker.WorkerTypes.SheepIt.RenderKey; + } + + if (worker.WorkerTypes.Flamenco != null) + { + HasFlamenco = true; + FlamencoWorkerPath = worker.WorkerTypes.Flamenco.WorkerPath; + NetworkDrives.Clear(); + foreach (var drive in worker.WorkerTypes.Flamenco.NetworkDrives) + { + NetworkDrives.Add(drive); + } + NetworkPaths.Clear(); + foreach (var path in worker.WorkerTypes.Flamenco.NetworkPaths) + { + NetworkPaths.Add(path); + } + } + } + + public WorkerConfig ToWorkerConfig() + { + var worker = new WorkerConfig + { + Id = Id, + Name = Name, + Enabled = Enabled, + Ssh = new SshConfig + { + Host = SshHost, + Port = SshPort, + Args = SshArgs + }, + WorkerTypes = new WorkerTypeConfig() + }; + + if (HasSheepIt) + { + worker.WorkerTypes.SheepIt = new SheepItConfig + { + Gpu = SheepItGpu, + Username = SheepItUsername, + RenderKey = SheepItRenderKey + }; + } + + if (HasFlamenco) + { + worker.WorkerTypes.Flamenco = new FlamencoConfig + { + WorkerPath = FlamencoWorkerPath, + NetworkDrives = NetworkDrives.ToList(), + NetworkPaths = NetworkPaths.ToList() + }; + } + + return worker; + } + + public void Save() + { + var worker = ToWorkerConfig(); + if (_isNew) + { + _configService.AddWorker(worker); + } + else + { + _configService.UpdateWorker(worker); + } + } + } +} + diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml new file mode 100644 index 0000000..9ede8e0 --- /dev/null +++ b/Views/MainWindow.axaml @@ -0,0 +1,53 @@ + + + + +