using System; using System.Collections.Generic; using System.IO; using System.Linq; using Deploymentcenter.Client; using Deploymentcenter.Client.Models; namespace Deploymentcenter.UpdateAgent { /// Was ein Anwenden tun wuerde beziehungsweise getan hat. internal sealed class ApplyPlan { /// Relative Pfade, die geschrieben werden. public List Write { get; } = new List(); /// Geschuetzte Dateien, die am Ziel bereits vorhanden sind. public List Preserved { get; } = new List(); /// Dateien der Vorversion, die es im neuen Release nicht mehr gibt. public List Orphans { get; } = new List(); /// Relativer Pfad des laufenden Agenten, falls er im Paket liegt. public string? SelfPath { get; set; } /// Konnten verwaiste Dateien ueberhaupt bestimmt werden? public bool OrphanDetectionPossible { get; set; } } /// /// Schreibt ein entpacktes Release ins Zielverzeichnis. /// /// Die Vorgaengerfassung war als "Atomic Replace with Backup" kommentiert, /// tatsaechlich aber eine Kopierschleife: kein Backup, kein Rollback, kein /// Aufraeumen. Brach sie in der Mitte ab - gesperrte Datei, volle Platte - /// blieb eine halb aktualisierte Installation zurueck, aus der kein Weg /// zurueckfuehrte. Dateien, die es im neuen Release nicht mehr gab, blieben /// ausserdem fuer immer liegen; bei .NET ein realer Weg in kaputte /// Assembly-Aufloesung. /// internal static class Installer { private const string BackupDirectoryName = ".dc-update-backup"; /// /// Endung, unter der eine laufende ausfuehrbare Datei zur Seite gelegt /// wird. Unter Windows laesst sich eine laufende Datei umbenennen, aber /// nicht ueberschreiben oder loeschen - genau darauf baut der /// Selbstaustausch auf. /// private const string ReplacedSuffix = ".dc-old"; /// /// Bestimmt, was zu tun ist, ohne etwas zu veraendern. /// public static ApplyPlan BuildPlan( string stagingDir, string targetDir, PackageManifest newManifest, PackageManifest? installedManifest, string? runningAgentPath) { var plan = new ApplyPlan(); var preservePatterns = newManifest.Preserve ?? new List(); var staged = Directory .GetFiles(stagingDir, "*", SearchOption.AllDirectories) .Select(f => GlobMatcher.Normalize(Path.GetRelativePath(stagingDir, f))) .ToList(); var stagedSet = new HashSet(staged, StringComparer.OrdinalIgnoreCase); foreach (string rel in staged) { string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)); // Geschuetzte Dateien werden nur bei der Erstinstallation // geschrieben. Ein Update darf die eingerichtete Konfiguration // des Zielsystems nicht ersetzen. if (File.Exists(targetPath) && GlobMatcher.IsMatch(rel, preservePatterns)) { plan.Preserved.Add(rel); continue; } if (runningAgentPath != null && IsSameFile(targetPath, runningAgentPath)) { plan.SelfPath = rel; } plan.Write.Add(rel); } // Verwaiste Dateien lassen sich nur bestimmen, wenn bekannt ist, // was die Vorversion mitgebracht hat. Ohne dieses Wissen wird // nichts geloescht - alles andere hiesse, fremde Dateien im // Zielverzeichnis zu entfernen. if (installedManifest?.Files != null && installedManifest.Files.Count > 0) { plan.OrphanDetectionPossible = true; foreach (var entry in installedManifest.Files) { string rel = GlobMatcher.Normalize(entry.Path); if (rel.Length == 0 || stagedSet.Contains(rel)) continue; if (GlobMatcher.IsMatch(rel, preservePatterns)) continue; string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(targetPath)) continue; if (runningAgentPath != null && IsSameFile(targetPath, runningAgentPath)) continue; plan.Orphans.Add(rel); } } return plan; } /// /// Fuehrt den Plan aus. Bei einem Fehler wird der Ausgangszustand /// wiederhergestellt und die Ausnahme weitergereicht. /// public static void Apply( string stagingDir, string targetDir, ApplyPlan plan, Action? log = null) { Directory.CreateDirectory(targetDir); string backupDir = Path.Combine(targetDir, BackupDirectoryName); // Ein Rest aus einem frueheren Abbruch wuerde die Wiederherstellung // mit fremden Daten fuettern. SafeDeleteDirectory(backupDir); Directory.CreateDirectory(backupDir); // Merkt sich je Datei, was zurueckzunehmen waere. var backedUp = new List(); var created = new List(); string? renamedSelf = null; try { foreach (string rel in plan.Write) { string sourcePath = Path.Combine(stagingDir, rel.Replace('/', Path.DirectorySeparatorChar)); string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); bool isSelf = plan.SelfPath != null && string.Equals(rel, plan.SelfPath, StringComparison.OrdinalIgnoreCase); if (File.Exists(targetPath)) { if (isSelf) { // Die laufende Datei kann nicht ueberschrieben, // wohl aber umbenannt werden. Ohne diesen Schritt // bricht das Update unter Windows mitten im // Kopieren mit einer Zugriffsverletzung ab - und // hinterliess bisher eine halbe Installation. renamedSelf = targetPath + ReplacedSuffix; SafeDelete(renamedSelf); File.Move(targetPath, renamedSelf); log?.Invoke($"Laufendes Agent-Binary zur Seite gelegt: {rel}"); } else { MoveToBackup(targetPath, targetDir, backupDir, rel); backedUp.Add(rel); } } else { created.Add(rel); } File.Copy(sourcePath, targetPath, overwrite: true); CopyExecutableBit(sourcePath, targetPath, rel); } foreach (string rel in plan.Orphans) { string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(targetPath)) continue; MoveToBackup(targetPath, targetDir, backupDir, rel); backedUp.Add(rel); log?.Invoke($"Entfernt (nicht mehr Teil des Releases): {rel}"); } } catch { Rollback(targetDir, backupDir, backedUp, created, plan.SelfPath, renamedSelf, log); throw; } // Erst jetzt ist der alte Stand entbehrlich. SafeDeleteDirectory(backupDir); // Die zur Seite gelegte eigene Datei laesst sich waehrend des // Laufens nicht loeschen; das erledigt der naechste Start. if (renamedSelf != null) { log?.Invoke("Der Agent hat sich selbst erneuert. Die alte Fassung wird beim naechsten Start entfernt."); } RemoveEmptyDirectories(targetDir); } /// /// Raeumt Reste eines Selbstaustauschs weg. Wird beim Start aufgerufen, /// weil die Datei zu diesem Zeitpunkt nicht mehr in Benutzung ist. /// public static void CleanupPreviousSelfUpdate(string directory) { try { if (!Directory.Exists(directory)) return; foreach (string leftover in Directory.GetFiles(directory, "*" + ReplacedSuffix, SearchOption.TopDirectoryOnly)) { SafeDelete(leftover); } } catch { // Ein liegengebliebener Rest ist unschoen, aber kein Grund, // den Start zu verweigern. } } private static void Rollback( string targetDir, string backupDir, List backedUp, List created, string? selfRel, string? renamedSelf, Action? log) { log?.Invoke("Update abgebrochen - stelle den vorherigen Stand wieder her ..."); // Neu angelegte Dateien wieder entfernen. foreach (string rel in created) { try { SafeDelete(Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar))); } catch { } } // Gesicherte Dateien zuruecklegen. foreach (string rel in backedUp) { try { string backupPath = Path.Combine(backupDir, rel.Replace('/', Path.DirectorySeparatorChar)); string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(backupPath)) continue; Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); SafeDelete(targetPath); File.Move(backupPath, targetPath); } catch (Exception ex) { log?.Invoke($"Wiederherstellung von {rel} fehlgeschlagen: {ex.Message}"); } } // Den eigenen Namen zuruecknehmen, falls schon umbenannt wurde. if (renamedSelf != null && selfRel != null) { try { string targetPath = Path.Combine(targetDir, selfRel.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(targetPath) && File.Exists(renamedSelf)) { File.Move(renamedSelf, targetPath); } } catch { } } log?.Invoke("Vorheriger Stand wiederhergestellt."); } private static void MoveToBackup(string targetPath, string targetDir, string backupDir, string rel) { string backupPath = Path.Combine(backupDir, rel.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); SafeDelete(backupPath); File.Move(targetPath, backupPath); } /// /// Uebertraegt das Ausfuehrbar-Bit. /// /// Wird unter Windows fuer linux-x64 gebaut, kennt das tar-Archiv keine /// Unix-Rechte und alles landet als 644 - die Anwendung liesse sich auf /// dem Zielsystem nicht starten. Betroffen sind der Apphost (traegt /// unter Linux keine Endung) und Shell-Skripte. /// private static void CopyExecutableBit(string sourcePath, string targetPath, string rel) { // Bewusst OperatingSystem.IsWindows() statt des eigenen Helfers: // nur diese Form erkennt die Plattformanalyse als Absicherung der // unter Windows nicht unterstuetzten Unix-Rechte-Aufrufe. if (OperatingSystem.IsWindows()) return; try { var mode = File.GetUnixFileMode(sourcePath); bool alreadyExecutable = (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0; if (!alreadyExecutable && LooksExecutable(rel)) { mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; } File.SetUnixFileMode(targetPath, mode); } catch { // Auf Dateisystemen ohne Unix-Rechte (etwa eine gemountete // Windows-Freigabe) ist das schlicht nicht anwendbar. } } private static bool LooksExecutable(string rel) { string name = rel; int slash = name.LastIndexOf('/'); if (slash >= 0) name = name.Substring(slash + 1); if (name.EndsWith(".sh", StringComparison.OrdinalIgnoreCase)) return true; // Der Apphost einer .NET-Anwendung traegt unter Linux keine // Endung. Dateien ohne Punkt sind hier die einzigen Kandidaten. return name.IndexOf('.') < 0; } /// /// Entfernt Verzeichnisse, die durch das Aufraeumen leer geworden sind. /// Das Zielverzeichnis selbst bleibt bestehen. /// private static void RemoveEmptyDirectories(string targetDir) { try { foreach (string dir in Directory .GetDirectories(targetDir, "*", SearchOption.AllDirectories) .OrderByDescending(d => d.Length)) { try { if (Directory.GetFileSystemEntries(dir).Length == 0) Directory.Delete(dir); } catch { } } } catch { } } private static bool IsSameFile(string a, string b) { try { string fullA = Path.GetFullPath(a); string fullB = Path.GetFullPath(b); var comparison = OperatingSystemHelpers.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; return string.Equals(fullA, fullB, comparison); } catch { return false; } } private static void SafeDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch { } } private static void SafeDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { } } } }