Behebt eine Reihe zusammenhaengender Fehler im Update-Weg, die zusammen verhindert haben, fuer mehr als eine Plattform auszuliefern - und die im Fehlerfall halb aktualisierte Installationen hinterliessen. Server - Migration 009: Spalte platform samt neuem Unique-Key. Zuvor verdraengte das zuletzt veroeffentlichte Paket alle anderen Plattformen derselben Version, weil ON DUPLICATE KEY auf (slug, version, channel) griff. Ein Linux-System zog sich damit das Windows-Paket. - Aufloesungsregel: je Version das plattformgenaue Paket, sonst das plattformunabhaengige. Ein Client ohne Plattformangabe sieht ausschliesslich 'any' - lieber kein Update als das falsche. - manifest_json wird endlich befuellt; die Spalte blieb bisher immer leer, wodurch die API nie der Rueckfall sein konnte, als der sie gedacht war. - Releases werden serverseitig mit RSA-SHA256 signiert, neuer Endpunkt /api/updateservice/v1/pubkey. Bewusst kein HMAC: der Pruefende laeuft auf fremden Systemen und darf den Signierschluessel nicht besitzen. Packager - Bricht ab, statt die Versionshistorie zu verlieren. Schlug das Lesen der bestehenden latest.json fehl, ersetzte ein leeres catch die komplette Historie durch einen einzigen Eintrag - ohne jede Meldung. - Echte Glob-Muster. Zuvor trafen "logs/**" und "scratch/**" aus der mitgelieferten Beispielkonfiguration nie zu. - preservePatterns: Konfigurationsvorlagen werden ausgeliefert, ersetzen am Ziel aber keine vorhandene Datei. Eine settings.json mit Zugangsdaten ueberschrieb bisher beim Update die Konfiguration jedes Zielsystems. - Warnt vor Dateien, die nach Zugangsdaten aussehen und auf keiner Liste stehen. - Prueft --version gegen die Hauptassembly. Eine Abweichung fuehrte zu einer Endlosschleife: Clients aktualisieren, melden weiter die alte Version, halten das Release erneut fuer neu. - --platform mit Ableitung aus dem Publish-Pfad. Agent - Anwenden mit Plan, Backup und vollstaendigem Rollback. Die Stelle war als "Atomic Replace with Backup" kommentiert und war eine Kopierschleife. - Verwaiste Dateien werden entfernt, aber nur solche aus dem Manifest der Vorversion. Was nicht aus einem Release stammt, bleibt liegen. - Das laufende Agent-Binary wird zur Seite gelegt statt ueberschrieben. - API-Rueckfall in FetchManifestAsync; bisher nur im SDK vorhanden, weshalb die Anwendung "Update verfuegbar" und der Agent "kein Release" sagen konnte. - Installierte Version aus --current-version oder manifest.json statt des Textes "Unbekannt", der als 0 gelesen wurde und jede Version neuer erscheinen liess. Reparatur funktioniert damit auch ohne manifest.json. - Setzt das Ausfuehrungsbit fuer Linux-Pakete, die unter Windows gebaut wurden. SDK - ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes "update-agent.exe" wird unter Linux nie gefunden. - LaunchUpdateAgent uebergibt jetzt --restart (wurde nie uebergeben, die Anwendung blieb nach dem Update zu), --wait-for-pid (kein Wettlauf mehr mit dem Herunterfahren) und --platform. Enthaelt ausserdem die bislang nicht committete Arbeit an Watchdog, Lizenz- Client und cli/tick.php samt Migration 008; die betroffenen Dateien liessen sich nicht getrennt stagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
427 lines
16 KiB
C#
427 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Deploymentcenter.Client;
|
|
using Deploymentcenter.Client.Models;
|
|
|
|
namespace Deploymentcenter.UpdateAgent
|
|
{
|
|
/// <summary>Was ein Anwenden tun wuerde beziehungsweise getan hat.</summary>
|
|
internal sealed class ApplyPlan
|
|
{
|
|
/// <summary>Relative Pfade, die geschrieben werden.</summary>
|
|
public List<string> Write { get; } = new List<string>();
|
|
|
|
/// <summary>Geschuetzte Dateien, die am Ziel bereits vorhanden sind.</summary>
|
|
public List<string> Preserved { get; } = new List<string>();
|
|
|
|
/// <summary>Dateien der Vorversion, die es im neuen Release nicht mehr gibt.</summary>
|
|
public List<string> Orphans { get; } = new List<string>();
|
|
|
|
/// <summary>Relativer Pfad des laufenden Agenten, falls er im Paket liegt.</summary>
|
|
public string? SelfPath { get; set; }
|
|
|
|
/// <summary>Konnten verwaiste Dateien ueberhaupt bestimmt werden?</summary>
|
|
public bool OrphanDetectionPossible { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal static class Installer
|
|
{
|
|
private const string BackupDirectoryName = ".dc-update-backup";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private const string ReplacedSuffix = ".dc-old";
|
|
|
|
/// <summary>
|
|
/// Bestimmt, was zu tun ist, ohne etwas zu veraendern.
|
|
/// </summary>
|
|
public static ApplyPlan BuildPlan(
|
|
string stagingDir,
|
|
string targetDir,
|
|
PackageManifest newManifest,
|
|
PackageManifest? installedManifest,
|
|
string? runningAgentPath)
|
|
{
|
|
var plan = new ApplyPlan();
|
|
var preservePatterns = newManifest.Preserve ?? new List<string>();
|
|
|
|
var staged = Directory
|
|
.GetFiles(stagingDir, "*", SearchOption.AllDirectories)
|
|
.Select(f => GlobMatcher.Normalize(Path.GetRelativePath(stagingDir, f)))
|
|
.ToList();
|
|
|
|
var stagedSet = new HashSet<string>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fuehrt den Plan aus. Bei einem Fehler wird der Ausgangszustand
|
|
/// wiederhergestellt und die Ausnahme weitergereicht.
|
|
/// </summary>
|
|
public static void Apply(
|
|
string stagingDir,
|
|
string targetDir,
|
|
ApplyPlan plan,
|
|
Action<string>? 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<string>();
|
|
var created = new List<string>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raeumt Reste eines Selbstaustauschs weg. Wird beim Start aufgerufen,
|
|
/// weil die Datei zu diesem Zeitpunkt nicht mehr in Benutzung ist.
|
|
/// </summary>
|
|
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<string> backedUp,
|
|
List<string> created,
|
|
string? selfRel,
|
|
string? renamedSelf,
|
|
Action<string>? 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Entfernt Verzeichnisse, die durch das Aufraeumen leer geworden sind.
|
|
/// Das Zielverzeichnis selbst bleibt bestehen.
|
|
/// </summary>
|
|
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 { }
|
|
}
|
|
}
|
|
}
|