UpdateService: Zugangsschutz, Plattform und sauberer Agent-Uebergang
Seit Deploymentcenter 2.4 liegt die Release-Ablage hinter HTTP-Basic-Auth mit dem Lizenzschluessel als Zugangsdatum. Ohne ihn antwortet jeder Paketabruf mit 401. Predictalytics hat noch kein Release veroeffentlicht; nach UPGRADE.md 16.1 entsteht der Schutz fuer ein Produkt mit dem ersten Upload, der erste ausgelieferte Build muss die Zugangsdaten also bereits mitbringen. - DcUpdateService reicht licenseKey und platform (PlatformId.Current) an CheckForUpdateAsync und LaunchUpdateAgent weiter. Fehlt der Schluessel am Aufruf, greift LicenseClient.TryGetCachedKey - damit ist der Headless-Pfad mit abgedeckt. - LaunchAgent startet ohne Lizenzschluessel gar nicht erst. Sonst schliesst sich die Anwendung, der Agent laeuft in einen 401, und zurueck bleibt eine geschlossene App ohne Update. - restartPath und waitForCurrentProcess werden gesetzt: der Agent wartet auf das Ende dieses Prozesses, statt auf noch gesperrte Assemblies zu schreiben, und startet die Anwendung danach wieder. Ein null-restartPath ist dafuer nicht brauchbar - das SDK ersetzt ihn durch denselben ProcessPath -, beim Start ueber "dotnet App.dll" wird deshalb der Apphost aufgeloest. - result.Unauthorized wird vor dem "ist aktuell"-Zweig behandelt. Bei 401 setzt das SDK weder Error noch UpdateAvailable; die Pruefung meldete bisher "aktuell", waehrend in Wirklichkeit keine Updates mehr ankamen. - Version zentral in Directory.Build.props, damit Hosting und Shell nicht auseinanderlaufen. - run.sh setzt unter Linux das beim Entpacken verlorene Ausfuehrungsbit. .gitattributes haelt Shell-Skripte auf LF: mit core.autocrlf=true traegt die Arbeitskopie sonst CRLF, und CopyToPublishDirectory nimmt genau die ins Linux-Paket - dort scheitert der Start an "bad interpreter". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Shell-Skripte immer mit LF auschecken, auch unter Windows.
|
||||
#
|
||||
# run.sh wird per CopyToPublishDirectory in das Linux-Paket uebernommen. Mit
|
||||
# core.autocrlf=true traegt die Arbeitskopie sonst CRLF, und die Zeile
|
||||
# "#!/usr/bin/env bash\r" laesst den Start dort an "bad interpreter" scheitern.
|
||||
*.sh text eol=lf
|
||||
@@ -1,5 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Version der Anwendung. Wird von Deploymentcenter.BuildInfo.targets,
|
||||
dem UpdateService, Fehler-Stream und Heartbeat genutzt. -->
|
||||
<Version>1.0.0</Version>
|
||||
<!-- Zielframework zentral. Ausnahme: Predictalytics.WinFormsHost setzt
|
||||
net10.0-windows, weil WinForms das Windows-Desktop-Pack braucht. -->
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
||||
@@ -5,54 +5,128 @@ namespace Predictalytics.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the Deployment Center UpdateService for a newer release
|
||||
/// (GET /api/updateservice/v1/check, no token needed).
|
||||
/// (GET /api/updateservice/v1/check).
|
||||
///
|
||||
/// The check itself only reads metadata. Installing is done by the standalone
|
||||
/// update-agent, which replaces the running installation and therefore has to be
|
||||
/// started explicitly by the user.
|
||||
///
|
||||
/// Since Deployment Center 2.4 the release directories sit behind HTTP basic auth:
|
||||
/// the license key is the credential. Without it every download answers 401, so the
|
||||
/// key has to travel with both the check and the agent handover.
|
||||
/// </summary>
|
||||
public static class DcUpdateService
|
||||
{
|
||||
private const string AgentFileName = "update-agent.exe";
|
||||
/// <summary>
|
||||
/// Runtime identifier the release directories are split by (win-x64, linux-x64, …).
|
||||
/// Taken from the SDK so app and agent agree on the spelling.
|
||||
/// </summary>
|
||||
public static string Platform => PlatformId.Current;
|
||||
|
||||
public static async Task<UpdateCheckResult> CheckAsync(string channel, CancellationToken ct = default)
|
||||
public static async Task<UpdateCheckResult> CheckAsync(
|
||||
string channel, string? licenseKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var client = new UpdateClient();
|
||||
return await client.CheckForUpdateAsync(
|
||||
baseUrl: DcConfig.BaseUrl,
|
||||
projectId: DcConfig.ProductSlug,
|
||||
currentVersion: DcConfig.AppVersion,
|
||||
channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel,
|
||||
channel: NormalizeChannel(channel),
|
||||
platform: Platform,
|
||||
credentials: ResolveCredentials(licenseKey),
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Path of the update agent next to the exe, or null if it was not deployed.</summary>
|
||||
public static string? FindUpdateAgent()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, AgentFileName);
|
||||
return File.Exists(path) ? path : null;
|
||||
return UpdateClient.ResolveAgentPath(AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands control to the update agent and ends this process. Returns false if the agent
|
||||
/// is not present — then the update has to be installed by hand.
|
||||
/// </summary>
|
||||
public static bool LaunchAgent(string channel)
|
||||
public static bool LaunchAgent(string channel, string? licenseKey = null)
|
||||
{
|
||||
var agent = FindUpdateAgent();
|
||||
if (agent is null)
|
||||
{
|
||||
Log.Warning("Update-Agent ({Agent}) liegt nicht neben der Anwendung — Update bitte manuell einspielen.", AgentFileName);
|
||||
Log.Warning("Update-Agent ({Agent}) liegt nicht neben der Anwendung — Update bitte manuell einspielen.",
|
||||
UpdateClient.AgentFileName);
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.Information("Starte Update-Agent {Agent} (Kanal {Channel}) und beende die Anwendung...", agent, channel);
|
||||
var key = ResolveLicenseKey(licenseKey);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
// The agent would download, get 401 and abort — with the app already closed.
|
||||
Log.Warning("Kein Lizenzschlüssel verfügbar — der Update-Agent käme nicht an das Paket. " +
|
||||
"Update wird nicht gestartet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var restartPath = ResolveRestartPath();
|
||||
Log.Information("Starte Update-Agent {Agent} (Kanal {Channel}, Plattform {Platform}) und beende die Anwendung...",
|
||||
agent, NormalizeChannel(channel), Platform);
|
||||
|
||||
return UpdateClient.LaunchUpdateAgent(
|
||||
agentPath: agent,
|
||||
projectId: DcConfig.ProductSlug,
|
||||
channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel,
|
||||
channel: NormalizeChannel(channel),
|
||||
action: "update",
|
||||
version: "latest",
|
||||
exitCurrentApp: true);
|
||||
exitCurrentApp: true,
|
||||
restartPath: restartPath,
|
||||
currentVersion: DcConfig.AppVersion,
|
||||
platform: Platform,
|
||||
// The agent waits for this process to go away before it copies over
|
||||
// the installation — otherwise it writes onto still-locked assemblies.
|
||||
waitForCurrentProcess: true,
|
||||
licenseKey: key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Basic-auth credentials for the protected release directories. Falls back to the key
|
||||
/// of the last successful activation, which also covers the headless path.
|
||||
/// </summary>
|
||||
private static ReleaseCredentials? ResolveCredentials(string? licenseKey)
|
||||
=> ReleaseCredentials.FromLicenseKey(ResolveLicenseKey(licenseKey));
|
||||
|
||||
private static string? ResolveLicenseKey(string? licenseKey)
|
||||
=> string.IsNullOrWhiteSpace(licenseKey)
|
||||
? LicenseClient.TryGetCachedKey(DcConfig.ProductSlug)
|
||||
: licenseKey;
|
||||
|
||||
/// <summary>
|
||||
/// Executable the agent should start again once it is done.
|
||||
///
|
||||
/// Passing null is not an option: the SDK then falls back to Environment.ProcessPath
|
||||
/// itself, so the "dotnet" case below cannot be opted out of — it has to be resolved
|
||||
/// to the apphost here.
|
||||
/// </summary>
|
||||
private static string? ResolveRestartPath()
|
||||
{
|
||||
var path = Environment.ProcessPath;
|
||||
|
||||
// Started as "dotnet Predictalytics.Shell.dll": ProcessPath is the shared runtime
|
||||
// host, and restarting that without its argument would launch nothing. The apphost
|
||||
// next to the entry assembly is what the user actually started from.
|
||||
if (path is null || Path.GetFileNameWithoutExtension(path).Equals("dotnet", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var entry = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name;
|
||||
if (!string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
var appHost = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
OperatingSystem.IsWindows() ? entry + ".exe" : entry!);
|
||||
if (File.Exists(appHost)) return appHost;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string NormalizeChannel(string channel)
|
||||
=> string.IsNullOrWhiteSpace(channel) ? "prod" : channel.Trim();
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Predictalytics.Hosting</RootNamespace>
|
||||
<!-- Wird als current_version an den UpdateService und als build an den Fehler-Stream
|
||||
gemeldet. Beim Release hier hochziehen. -->
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
CopyToPublishDirectory="PreserveNewest" />
|
||||
<None Include="run.sh" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -191,7 +191,24 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await DcUpdateService.CheckAsync(Options.DcUpdateChannel);
|
||||
var result = await DcUpdateService.CheckAsync(Options.DcUpdateChannel, License?.LicenseKey);
|
||||
|
||||
// 401 is a licensing statement, not a network hiccup — and it must be caught
|
||||
// before the "up to date" branch: the server answers no version at all, so
|
||||
// silently reporting "aktuell" would hide that updates stopped arriving.
|
||||
if (result.Unauthorized)
|
||||
{
|
||||
Log.Error("⛔ Die Release-Ablage weist die Lizenz zurück — es kommen keine Updates mehr an: {Message}",
|
||||
result.Message);
|
||||
BuildVersionText = $"v{DcConfig.AppVersion} — Update-Zugang abgelehnt";
|
||||
if (!silent && ShowError != null)
|
||||
await ShowError("Deployment Center",
|
||||
"Die Release-Ablage hat den Lizenzschlüssel abgelehnt (401).\n\n" +
|
||||
$"{result.Message}\n\n" +
|
||||
"Das ist kein Netzwerkproblem: Die Lizenz ist abgelaufen oder widerrufen. " +
|
||||
"Solange das so bleibt, erreichen diese Installation keine Updates.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Error is not null)
|
||||
{
|
||||
@@ -237,7 +254,11 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
// The agent replaces the running installation, so announce the shutdown first —
|
||||
// otherwise the monitor reports a crash a few minutes later.
|
||||
_heartbeat?.NotifyStopping();
|
||||
DcUpdateService.LaunchAgent(Options.DcUpdateChannel);
|
||||
if (!DcUpdateService.LaunchAgent(Options.DcUpdateChannel, License?.LicenseKey) && ShowError != null)
|
||||
{
|
||||
await ShowError("Deployment Center",
|
||||
"Der Update-Agent konnte nicht gestartet werden. Details stehen im Log.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
chmod +x "$SCRIPT_DIR/Predictalytics.Shell" "$SCRIPT_DIR/update-agent" 2>/dev/null
|
||||
exec "$SCRIPT_DIR/Predictalytics.Shell" "$@"
|
||||
Reference in New Issue
Block a user