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:
Deploymentcenter Bot
2026-08-09 19:56:35 +02:00
co-authored by Claude Opus 5
parent 5f9b0c5596
commit 2388b5abe1
37 changed files with 5498 additions and 491 deletions
@@ -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 { }
}
}
}