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 { }
}
}
}