using System.IO.Compression; using System.Linq; using Renci.SshNet; namespace GB5DailyBuild; internal static class DeployRunner { private const int MaxRetainedBuilds = 7; public static DeployResult DeployWindows(string zipPath, string mergeName, WindowsDeploySettings settings) { var destRoot = Environment.ExpandEnvironmentVariables(settings.Path); var destZip = Path.Combine(destRoot, $"{mergeName}.zip"); if (destRoot.StartsWith(@"\\") && !string.IsNullOrEmpty(settings.Username)) { EnsureUncConnection(destRoot, settings.Username!, settings.Password ?? ""); } Console.WriteLine($"Copying {zipPath} -> {destZip}"); try { Directory.CreateDirectory(destRoot); File.Copy(zipPath, destZip, overwrite: true); Console.WriteLine("Windows deploy complete."); PruneOldBuilds(destRoot); return DeployResult.Ok(destZip); } catch (Exception ex) { Console.WriteLine($"Windows deploy FAILED: {ex.Message}"); return DeployResult.Failed(ex.Message); } } // net use sessions to a UNC share can silently drop (reboot, network blip, // credential expiry) even when originally created with /persistent:yes. // Re-authenticating before every deploy makes the tool self-heal instead of // depending on whatever state the OS happens to be in. private static void EnsureUncConnection(string uncPath, string username, string password) { // Extract \\server\share from \\server\share\subfolder\... var parts = uncPath.TrimStart('\\').Split('\\'); if (parts.Length < 2) { return; } var shareRoot = $@"\\{parts[0]}\{parts[1]}"; Console.WriteLine($"Ensuring network connection to {shareRoot}..."); ProcessRunner.Run("net", $"use \"{shareRoot}\" /delete /y", Path.GetTempPath(), logCommand: false); var connectExit = ProcessRunner.Run("net", $"use \"{shareRoot}\" \"{password}\" /user:{username} /persistent:yes", Path.GetTempPath(), logCommand: false); if (connectExit != 0) { Console.WriteLine($"Warning: net use to {shareRoot} returned exit code {connectExit} - deploy may fail if the share is unreachable."); } } // Keeps only the MaxRetainedBuilds most recent "GB5 Build - *.zip" files in a // folder, deleting older ones. Applied after every successful deploy so the // destination never grows unbounded. private static void PruneOldBuilds(string folder) { try { var oldBuilds = new DirectoryInfo(folder) .GetFiles("GB5 Build - *.zip") .OrderByDescending(f => f.LastWriteTimeUtc) .Skip(MaxRetainedBuilds); foreach (var file in oldBuilds) { Console.WriteLine($"Pruning old build: {file.FullName}"); file.Delete(); } } catch (Exception ex) { Console.WriteLine($"Warning: could not prune old builds in {folder}: {ex.Message}"); } } // Builds a SSH.NET ConnectionInfo dynamically: password auth if configured // (shared credential in appsettings.json, works identically on every machine // with zero installation - no sshpass, no PATH issues, nothing to install), // otherwise falls back to key-based auth for machines that already have a // key configured. Pure managed code - no external ssh/scp/sshpass process // at all, so it can never be broken by a stale PATH or missing executable. private static (ConnectionInfo? Info, string? Error) ResolveConnectionInfo( string host, string user, string? password, string? sshKeyPath, string toolLabel) { if (!string.IsNullOrEmpty(password)) { return (new ConnectionInfo(host, user, new PasswordAuthenticationMethod(user, password)), null); } var keyPath = Environment.ExpandEnvironmentVariables(sshKeyPath ?? ""); if (string.IsNullOrEmpty(keyPath) || !File.Exists(keyPath)) { return (null, $"No password configured and SSH key not found at {keyPath}. Set {toolLabel}Deploy:Password in appsettings.json, or place a key at that path."); } var keyFile = new PrivateKeyFile(keyPath); return (new ConnectionInfo(host, user, new PrivateKeyAuthenticationMethod(user, keyFile)), null); } public static DeployResult DeployLinux(string zipPath, string mergeName, LinuxDeploySettings settings) { var (connectionInfo, error) = ResolveConnectionInfo(settings.Host, settings.User, settings.Password, settings.SshKeyPath, "Linux"); if (connectionInfo is null) { Console.WriteLine($"Linux deploy SKIPPED: {error}"); return DeployResult.Failed(error!); } var remoteDir = "/var/www/GB5DailyBuild"; var remoteZipPath = $"{remoteDir}/{mergeName}.zip"; var target = $"{settings.User}@{settings.Host}"; try { using var sshClient = new SshClient(connectionInfo); sshClient.Connect(); var mkdirCmd = sshClient.RunCommand($"mkdir -p '{remoteDir}'"); if (mkdirCmd.ExitStatus != 0) { var msg = $"could not create remote directory (exit code {mkdirCmd.ExitStatus}): {mkdirCmd.Error}"; Console.WriteLine($"Linux deploy FAILED: {msg}"); return DeployResult.Failed(msg); } Console.WriteLine($"Deploying {zipPath} -> {target}:{remoteZipPath}"); using (var sftpClient = new SftpClient(connectionInfo)) { sftpClient.Connect(); using var fileStream = File.OpenRead(zipPath); sftpClient.UploadFile(fileStream, remoteZipPath, canOverride: true); sftpClient.Disconnect(); } Console.WriteLine("Linux deploy complete."); // Keep only the MaxRetainedBuilds most recent zips on the remote server - // ls -t sorts newest-first, tail -n +N+1 skips the first N (the ones to keep). var pruneCmd = $"cd {remoteDir} && ls -t \"GB5 Build - \"*.zip 2>/dev/null | tail -n +{MaxRetainedBuilds + 1} | xargs -r rm -f --"; sshClient.RunCommand(pruneCmd); sshClient.Disconnect(); return DeployResult.Ok($"{target}:{remoteZipPath}"); } catch (Exception ex) { var msg = $"SSH/SCP failed: {ex.Message}. Check network connectivity and credentials to {settings.Host}."; Console.WriteLine($"Linux deploy FAILED: {msg}"); return DeployResult.Failed(msg); } } public static DeployResult DeployDocker(string repoRoot, string version, DockerDeploySettings settings) { var (connectionInfo, error) = ResolveConnectionInfo(settings.Host, settings.User, settings.Password, settings.SshKeyPath, "Docker"); if (connectionInfo is null) { Console.WriteLine($"Docker deploy SKIPPED: {error}"); return DeployResult.Failed(error!); } var target = $"{settings.User}@{settings.Host}"; var remoteDir = $"/root/gb5-docker-build/{version}"; const string image = "frameworksl"; var localStage = Path.Combine(Path.GetTempPath(), $"gb5-docker-src-{version}"); if (Directory.Exists(localStage)) { Directory.Delete(localStage, recursive: true); } Console.WriteLine("Staging source for Docker build (excludes GB5BuildFile, .git, .claude, .vs, bin, obj, *.log)..."); // .claude is excluded because .claude/worktrees/** contains deeply nested paths // that exceed Windows MAX_PATH (260 chars) and abort robocopy on that subtree. // *.log is excluded because stray debug/build logs at the repo root can reach // hundreds of MB and add nothing to the Docker build context. var robocopyArgs = $"\"{repoRoot}\" \"{localStage}\" /E /XD GB5BuildFile .git .claude .vs bin obj node_modules /XF *.log /XJ /R:1 /W:1 /NFL /NDL /NJH /NJS"; var robocopyExit = ProcessRunner.Run("robocopy", robocopyArgs, repoRoot); // robocopy exit codes 0-7 are all success (bit flags for "files copied/skipped/mismatched"); // only 8+ indicates a real failure. if (robocopyExit >= 8) { var msg = $"robocopy staging failed (exit code {robocopyExit})."; Console.WriteLine($"Docker deploy FAILED: {msg}"); return DeployResult.Failed(msg); } if (!Directory.Exists(localStage) || Directory.GetFileSystemEntries(localStage).Length == 0) { const string msg = "staged source folder is empty after robocopy."; Console.WriteLine($"Docker deploy FAILED: {msg}"); return DeployResult.Failed(msg); } var localZip = Path.Combine(Path.GetTempPath(), $"gb5-docker-src-{version}.zip"); if (File.Exists(localZip)) { File.Delete(localZip); } try { // Transfer as a single zip instead of thousands of individual source files - // uploading the tree file-by-file over SSH can take an hour due to per-file // handshake overhead; one compressed archive takes seconds to minutes. Console.WriteLine("Compressing staged source into a single archive for transfer..."); ZipFile.CreateFromDirectory(localStage, localZip, CompressionLevel.Fastest, includeBaseDirectory: false); using var sshClient = new SshClient(connectionInfo); sshClient.Connect(); var remoteZipPath = $"{remoteDir}/src.zip"; var mkdirCmd = sshClient.RunCommand($"mkdir -p '{remoteDir}'"); if (mkdirCmd.ExitStatus != 0) return Fail($"could not create remote directory (exit code {mkdirCmd.ExitStatus}): {mkdirCmd.Error}"); Console.WriteLine($"Copying {localZip} -> {target}:{remoteZipPath}"); using (var sftpClient = new SftpClient(connectionInfo)) { sftpClient.Connect(); using var fileStream = File.OpenRead(localZip); sftpClient.UploadFile(fileStream, remoteZipPath, canOverride: true); sftpClient.Disconnect(); } Console.WriteLine("Extracting archive on remote server..."); var unzipCmd = sshClient.RunCommand($"cd {remoteDir} && unzip -q -o src.zip && rm -f src.zip"); if (unzipCmd.ExitStatus != 0) return Fail($"remote unzip failed (exit code {unzipCmd.ExitStatus}): {unzipCmd.Error} - ensure 'unzip' is installed on {settings.Host}"); Console.WriteLine("Building image on remote Docker server..."); var buildCommand = sshClient.CreateCommand($"cd {remoteDir} && docker build -t {image}:{version} -t {image}:latest -f Dockerfile ."); buildCommand.CommandTimeout = TimeSpan.FromMinutes(30); var buildOutput = buildCommand.Execute(); Console.WriteLine(buildOutput); if (buildCommand.ExitStatus != 0) return Fail($"docker build failed on remote server (exit code {buildCommand.ExitStatus}) - see console output above for the compiler/docker error"); // Push to the private registry (self-hosted, insecure/HTTP on port 5050) so // other machines can `docker pull` this build instead of it only existing in // this server's local image cache. const string registry = "217.216.78.142:5050"; var registryImage = $"{registry}/{image}"; Console.WriteLine($"Pushing image to registry {registry}..."); var tagVersionCmd = sshClient.RunCommand($"docker tag {image}:{version} {registryImage}:{version}"); var tagLatestCmd = sshClient.RunCommand($"docker tag {image}:{version} {registryImage}:latest"); if (tagVersionCmd.ExitStatus != 0 || tagLatestCmd.ExitStatus != 0) return Fail($"docker tag failed (version exit {tagVersionCmd.ExitStatus}, latest exit {tagLatestCmd.ExitStatus}): {tagVersionCmd.Error}{tagLatestCmd.Error}"); var pushVersionCommand = sshClient.CreateCommand($"docker push {registryImage}:{version}"); pushVersionCommand.CommandTimeout = TimeSpan.FromMinutes(15); var pushVersionOutput = pushVersionCommand.Execute(); Console.WriteLine(pushVersionOutput); if (pushVersionCommand.ExitStatus != 0) return Fail($"docker push failed for {registryImage}:{version} (exit code {pushVersionCommand.ExitStatus})"); var pushLatestCommand = sshClient.CreateCommand($"docker push {registryImage}:latest"); pushLatestCommand.CommandTimeout = TimeSpan.FromMinutes(15); var pushLatestOutput = pushLatestCommand.Execute(); Console.WriteLine(pushLatestOutput); if (pushLatestCommand.ExitStatus != 0) return Fail($"docker push failed for {registryImage}:latest (exit code {pushLatestCommand.ExitStatus})"); sshClient.RunCommand($"rm -rf '{remoteDir}'"); sshClient.Disconnect(); Console.WriteLine($"Docker image {image}:{version} built on {settings.Host}."); return DeployResult.Ok($"{image}:{version} on {settings.Host}"); } catch (Exception ex) { return Fail($"SSH/SCP failed: {ex.Message}"); } finally { if (Directory.Exists(localStage)) { Directory.Delete(localStage, recursive: true); } if (File.Exists(localZip)) { File.Delete(localZip); } } DeployResult Fail(string reason) { Console.WriteLine($"Docker deploy FAILED: {reason}."); return DeployResult.Failed(reason); } } private static void CopyDirectory(string sourceDir, string destDir) { Directory.CreateDirectory(destDir); foreach (var file in Directory.GetFiles(sourceDir)) { File.Copy(file, Path.Combine(destDir, Path.GetFileName(file)), overwrite: true); } foreach (var subDir in Directory.GetDirectories(sourceDir)) { CopyDirectory(subDir, Path.Combine(destDir, Path.GetFileName(subDir))); } } }