feat(updateservice): Plattform-Dimension, signierte Releases, Update mit Rollback
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f9b0c5596
commit
2388b5abe1
@@ -0,0 +1,426 @@
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user