begin application build overhaul
This commit is contained in:
41
Services/AttachService.cs
Normal file
41
Services/AttachService.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
Services/ConfigService.cs
Normal file
128
Services/ConfigService.cs
Normal file
@@ -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<ConfigRoot>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
307
Services/SshService.cs
Normal file
307
Services/SshService.cs
Normal file
@@ -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<string> Options { get; set; } = new();
|
||||
public int? Port { get; set; }
|
||||
public bool RequestPty { get; set; }
|
||||
}
|
||||
|
||||
public class SshService
|
||||
{
|
||||
private readonly Dictionary<string, string> _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>();
|
||||
string? targetHost = null;
|
||||
int? port = null;
|
||||
bool requestPty = false;
|
||||
|
||||
var optionsWithArgs = new HashSet<string> { "-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<string> BuildSshArgs(SshConnectionParts parts, bool interactive)
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
"-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<string> BuildScpArgs(SshConnectionParts parts)
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
"-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<string> 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<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
344
Services/WorkerControllerService.cs
Normal file
344
Services/WorkerControllerService.cs
Normal file
@@ -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))}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user