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
@@ -43,21 +43,31 @@ namespace Deploymentcenter.Client
/// <summary>
/// Checks for update availability against LEMP static latest.json or Deploymentcenter API.
/// </summary>
/// <param name="platform">
/// Laufzeitkennung des Systems (z. B. "win-x64"). Ohne Angabe wird die
/// des laufenden Prozesses verwendet. Wird bewusst mitgeschickt: ohne
/// sie liefert der Server nur plattformunabhaengige Releases, damit ein
/// Client nie das Paket einer fremden Plattform angeboten bekommt.
/// </param>
public async Task<UpdateCheckResult> CheckForUpdateAsync(
string baseUrl,
string projectId,
string currentVersion,
string channel = "prod",
string? platform = null,
CancellationToken cancellationToken = default)
{
var result = new UpdateCheckResult();
try
{
string cleanBaseUrl = baseUrl.TrimEnd('/');
string rid = PlatformId.Normalize(platform ?? PlatformId.Current);
// Primary check: LEMP static channel latest.json
// Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json
string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}/latest.json";
// Plattformunabhaengige Releases liegen weiterhin unter dem
// alten Pfad ohne Zwischenebene, damit bereits ausgelieferte
// Anwendungen ihre Updates finden.
string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}{PlatformId.PathSegment(rid)}/latest.json";
// Zuerst die statische latest.json, danach die API.
//
@@ -84,7 +94,8 @@ namespace Deploymentcenter.Client
string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check"
+ $"?product={Uri.EscapeDataString(projectId)}"
+ $"&version={Uri.EscapeDataString(currentVersion)}"
+ $"&channel={Uri.EscapeDataString(channel)}";
+ $"&channel={Uri.EscapeDataString(channel)}"
+ $"&platform={Uri.EscapeDataString(rid)}";
response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
}
@@ -121,16 +132,41 @@ namespace Deploymentcenter.Client
}
}
// Handle API response format
//
// Die API antwortet in snake_case und reicht die Release-Zeile
// aus updateservice_releases durch. Sie wird deshalb ueber
// ApiReleaseInfo gelesen und erst danach in das Modell
// uebersetzt, das Aufrufer kennen. Zuvor wurde direkt nach
// VersionInfo deserialisiert - dessen camelCase-Namen gehoeren
// aber zur statischen latest.json, sodass ausser "version"
// nichts ankam.
else if (root.TryGetProperty("update_available", out var availProp))
{
bool available = availProp.GetBoolean();
bool available = availProp.ValueKind == JsonValueKind.True
|| (availProp.ValueKind == JsonValueKind.Number && availProp.GetInt32() != 0);
result.UpdateAvailable = available;
if (root.TryGetProperty("latest_release", out var relProp))
if (root.TryGetProperty("latest_release", out var relProp)
&& relProp.ValueKind == JsonValueKind.Object)
{
var info = JsonSerializer.Deserialize<VersionInfo>(relProp.GetRawText());
result.LatestRelease = info;
result.IsCritical = info?.IsCritical ?? false;
var info = JsonSerializer.Deserialize<ApiReleaseInfo>(relProp.GetRawText());
result.LatestRelease = info?.ToVersionInfo();
}
// is_critical steht auf oberster Ebene der Antwort, nicht im
// Release-Objekt. Vorher wurde es aus dem deserialisierten
// Objekt gelesen und war damit immer false - ein kritisches
// Release wurde ueber diesen Weg nie als kritisch gemeldet.
if (root.TryGetProperty("is_critical", out var critProp))
{
result.IsCritical = ReadFlexibleBool(critProp);
}
else
{
result.IsCritical = result.LatestRelease?.IsCritical ?? false;
}
result.Message = available ? "Update available." : "Application is up to date.";
}
}
@@ -143,6 +179,28 @@ namespace Deploymentcenter.Client
return result;
}
/// <summary>
/// Liest einen Wahrheitswert, der als Boolean, Zahl oder Zeichenkette
/// ankommen kann. MySQL liefert TINYINT(1), PHP gibt es je nach
/// PDO-Einstellung als 1 oder "1" weiter.
/// </summary>
private static bool ReadFlexibleBool(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.True:
return true;
case JsonValueKind.Number:
return element.TryGetInt64(out long number) && number != 0;
case JsonValueKind.String:
string value = (element.GetString() ?? string.Empty).Trim();
if (bool.TryParse(value, out bool parsed)) return parsed;
return long.TryParse(value, out long numeric) && numeric != 0;
default:
return false;
}
}
/// <summary>
/// Validates local application integrity against manifest.json.
/// </summary>
@@ -181,8 +239,56 @@ namespace Deploymentcenter.Client
}
/// <summary>
/// Launches UpdateAgent process with appropriate parameters and optionally exits current application.
/// Dateiname des Agenten auf dieser Plattform.
///
/// Unter Linux und macOS traegt das Binary keine Endung. Ein fest auf
/// "update-agent.exe" verdrahteter Name wird dort nie gefunden, und die
/// Anwendung meldet stumm "kein Agent vorhanden".
/// </summary>
public static string AgentFileName =>
OperatingSystemHelpers.IsWindows() ? "update-agent.exe" : "update-agent";
/// <summary>
/// Sucht den Agenten. Ohne Verzeichnisangabe wird neben der laufenden
/// Anwendung gesucht.
/// </summary>
public static string? ResolveAgentPath(string? directory = null)
{
string dir = string.IsNullOrWhiteSpace(directory)
? AppDomain.CurrentDomain.BaseDirectory
: directory!;
string candidate = Path.Combine(dir, AgentFileName);
if (File.Exists(candidate))
return candidate;
// Ein Paket, das fuer die jeweils andere Plattform gebaut wurde,
// bringt den Agenten unter dem dortigen Namen mit. Lieber finden
// als daran scheitern.
foreach (string alternative in new[] { "update-agent", "update-agent.exe" })
{
string path = Path.Combine(dir, alternative);
if (File.Exists(path))
return path;
}
return null;
}
/// <summary>
/// Startet den UpdateAgent und beendet auf Wunsch die laufende Anwendung.
/// </summary>
/// <param name="restartPath">
/// Anwendung, die der Agent nach dem Update wieder starten soll.
/// Wurde dieser Wert zuvor nie uebergeben - der Agent unterstuetzte
/// <c>--restart</c>, bekam es aber nie zu sehen, sodass die Anwendung
/// nach "Jetzt installieren" schlicht geschlossen blieb.
/// </param>
/// <param name="waitForCurrentProcess">
/// Uebergibt die eigene Prozesskennung, damit der Agent das Ende der
/// Anwendung abwartet, bevor er Dateien ersetzt. Ohne diesen Handschlag
/// kopiert er bei langsamem Herunterfahren ueber gesperrte Dateien.
/// </param>
public static bool LaunchUpdateAgent(
string agentPath,
string projectId,
@@ -190,7 +296,11 @@ namespace Deploymentcenter.Client
string action = "update",
string version = "latest",
string? targetDir = null,
bool exitCurrentApp = true)
bool exitCurrentApp = true,
string? restartPath = null,
string? currentVersion = null,
string? platform = null,
bool waitForCurrentProcess = true)
{
if (!File.Exists(agentPath))
{
@@ -200,16 +310,41 @@ namespace Deploymentcenter.Client
targetDir ??= AppDomain.CurrentDomain.BaseDirectory;
var args = new StringBuilder();
args.Append($"--project \"{projectId}\" ");
args.Append($"--channel \"{channel}\" ");
args.Append($"--action \"{action}\" ");
args.Append($"--version \"{version}\" ");
args.Append($"--target-dir \"{targetDir}\"");
AppendArg(args, "--project", projectId);
AppendArg(args, "--channel", channel);
AppendArg(args, "--action", action);
AppendArg(args, "--version", version);
AppendArg(args, "--target-dir", targetDir);
AppendArg(args, "--platform", PlatformId.Normalize(platform ?? PlatformId.Current));
if (!string.IsNullOrWhiteSpace(currentVersion))
{
AppendArg(args, "--current-version", currentVersion!);
}
if (waitForCurrentProcess)
{
AppendArg(args, "--wait-for-pid",
Process.GetCurrentProcess().Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
// Ohne ausdruecklichen Pfad die eigene ausfuehrbare Datei.
string? restart = restartPath;
if (string.IsNullOrWhiteSpace(restart))
{
restart = GetCurrentExecutablePath();
}
if (!string.IsNullOrWhiteSpace(restart))
{
AppendArg(args, "--restart", restart!);
}
var startInfo = new ProcessStartInfo
{
FileName = agentPath,
Arguments = args.ToString(),
Arguments = args.ToString().TrimEnd(),
WorkingDirectory = Path.GetDirectoryName(agentPath) ?? targetDir,
UseShellExecute = true
};
@@ -223,6 +358,34 @@ namespace Deploymentcenter.Client
return true;
}
private static void AppendArg(StringBuilder builder, string name, string value)
{
builder.Append(name);
builder.Append(" \"");
// Ein abschliessender Backslash wuerde sonst das Anfuehrungszeichen
// maskieren und alle folgenden Argumente verschlucken - bei
// Windows-Pfaden wie C:\App\ ein realer Fall.
builder.Append(value.Replace("\"", "\\\"").TrimEnd('\\'));
builder.Append("\" ");
}
private static string? GetCurrentExecutablePath()
{
try
{
#if NET8_0_OR_GREATER
string? path = Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(path))
return path;
#endif
return Process.GetCurrentProcess().MainModule?.FileName;
}
catch
{
return null;
}
}
public static string ComputeSha256(string filePath)
{
using var sha256 = SHA256.Create();