The dot replaces the small 'new' badge; the first update check now starts as soon as the panel asks for the update status instead of 60 s after start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
649 lines
28 KiB
C#
649 lines
28 KiB
C#
using System.Diagnostics;
|
|
using System.IO.Compression;
|
|
using System.Reflection;
|
|
using System.Runtime.Loader;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace MSCO.Server;
|
|
|
|
/// <summary>
|
|
/// Server-Updates aus dem Git-Repository (git.mbm.wtf). Prüft regelmäßig, ob auf "main" eine neuere Version
|
|
/// (<Version> in MSCO.Server.csproj) liegt. Installieren im Panel: Quellcode laden, mit dem .NET SDK bauen
|
|
/// (dotnet publish) und nach DATA/app/current legen, dann neu starten. Beim Start lädt das Programm von dort die neuere
|
|
/// Version (siehe RunInstalled) - so klappt es auch in Docker, wo /app zum Image gehört. Ein neueres Image
|
|
/// (docker compose up --build) gewinnt wieder gegen ein älteres installiertes Update.
|
|
/// </summary>
|
|
public sealed class Updater
|
|
{
|
|
/// <summary>Exit-Code "neu starten für das Update" - Docker (restart: unless-stopped) und systemd starten neu.</summary>
|
|
public const int RestartExitCode = 75;
|
|
const int MaxStartAttempts = 3;
|
|
static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
|
static readonly TimeSpan BuildTimeout = TimeSpan.FromMinutes(15);
|
|
const int MaxLogLines = 400;
|
|
|
|
/// <summary>Quelle der Updates (für Forks/Tests per MSCO_UPDATE_SOURCE änderbar).</summary>
|
|
public static string Source => (Environment.GetEnvironmentVariable("MSCO_UPDATE_SOURCE") ?? "https://git.mbm.wtf/MrMaho/MSCO-Dedicated-Server").TrimEnd('/');
|
|
public static string Branch => Environment.GetEnvironmentVariable("MSCO_UPDATE_BRANCH") ?? "main";
|
|
static string Api
|
|
{
|
|
get
|
|
{
|
|
var u = new Uri(Source);
|
|
return $"{u.Scheme}://{u.Authority}/api/v1/repos{u.AbsolutePath.TrimEnd('/')}";
|
|
}
|
|
}
|
|
|
|
static readonly HttpClient Http = CreateHttp();
|
|
|
|
static HttpClient CreateHttp()
|
|
{
|
|
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
|
http.DefaultRequestHeaders.UserAgent.ParseAdd("MSCO-Server/" + CurrentVersion);
|
|
return http;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- eigene Version
|
|
|
|
static string Informational =>
|
|
typeof(Updater).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0.0.0";
|
|
|
|
/// <summary>Version dieses Programms (aus MSCO.Server.csproj), z.B. "2.9.0".</summary>
|
|
public static string CurrentVersion => Informational.Split('+')[0];
|
|
|
|
/// <summary>Git-Commit dieses Builds (nur bei per Update gebauten Versionen bekannt).</summary>
|
|
public static string CurrentCommit
|
|
{
|
|
get
|
|
{
|
|
int plus = Informational.IndexOf('+');
|
|
return plus < 0 ? null : Informational[(plus + 1)..];
|
|
}
|
|
}
|
|
|
|
public static bool IsNewer(string candidate, string than) =>
|
|
Version.TryParse(candidate, out var a) && Version.TryParse(than, out var b) && a > b;
|
|
|
|
/// <summary><Version> aus einer .csproj-Datei.</summary>
|
|
public static string ParseCsprojVersion(string csproj)
|
|
{
|
|
var m = Regex.Match(csproj ?? "", @"<Version>\s*([0-9][0-9.]*)\s*</Version>");
|
|
return m.Success ? m.Groups[1].Value : null;
|
|
}
|
|
|
|
/// <summary>Mod-Version (Protokoll) aus GameServer.cs.</summary>
|
|
public static string ParseModVersion(string source)
|
|
{
|
|
var m = Regex.Match(source ?? "", @"ModVersion\s*=\s*""([^""]+)""");
|
|
return m.Success ? m.Groups[1].Value : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Zustand fürs Panel
|
|
|
|
public enum Step { Idle, Downloading, Building, Installing, Restarting, Failed }
|
|
|
|
public sealed class Commit
|
|
{
|
|
public string Sha, Title, Date;
|
|
}
|
|
|
|
sealed class Latest
|
|
{
|
|
public string Version, Commit, Date, Message, ModVersion;
|
|
public List<Commit> Changes = new();
|
|
}
|
|
|
|
readonly ServerHost host;
|
|
readonly string dataDir;
|
|
readonly object gate = new();
|
|
readonly List<string> log = new();
|
|
Latest latest;
|
|
DateTime? lastCheckUtc;
|
|
string checkError;
|
|
Step step = Step.Idle;
|
|
int progress;
|
|
string message, error, target, startedBy;
|
|
DateTime stepSinceUtc = DateTime.UtcNow;
|
|
bool notified;
|
|
(bool ok, string reason)? sdk;
|
|
|
|
public Updater(ServerHost host, string dataDir)
|
|
{
|
|
this.host = host;
|
|
this.dataDir = dataDir;
|
|
}
|
|
|
|
string AppDir => Path.Combine(dataDir, "app");
|
|
string WorkDir => Path.Combine(dataDir, "update");
|
|
|
|
/// <summary>Hintergrund: kurz nach dem Start und dann alle 6 Stunden prüfen; neu installierte Version als gut markieren.</summary>
|
|
public void StartBackground()
|
|
{
|
|
_ = Task.Run(async () =>
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(60));
|
|
MarkHealthy(); // eine Minute ohne Absturz: das installierte Update läuft
|
|
while (true)
|
|
{
|
|
try { await CheckAsync(); } catch { }
|
|
await Task.Delay(CheckInterval);
|
|
}
|
|
});
|
|
}
|
|
|
|
bool firstCheckStarted;
|
|
|
|
public object Status()
|
|
{
|
|
// Noch nie geprüft (gerade erst gestartet): jetzt im Hintergrund prüfen, damit das Panel den Punkt bald zeigt
|
|
if (lastCheckUtc == null && !firstCheckStarted)
|
|
{
|
|
firstCheckStarted = true;
|
|
_ = Task.Run(async () => { try { await CheckAsync(); } catch { } });
|
|
}
|
|
lock (gate)
|
|
{
|
|
var sdkInfo = sdk;
|
|
return new
|
|
{
|
|
current = CurrentVersion,
|
|
currentCommit = CurrentCommit,
|
|
modVersion = GameServer.ModVersion,
|
|
source = Source,
|
|
branch = Branch,
|
|
latest = latest?.Version,
|
|
latestCommit = latest?.Commit,
|
|
latestDate = latest?.Date,
|
|
latestMessage = latest?.Message,
|
|
latestModVersion = latest?.ModVersion,
|
|
available = latest != null && IsNewer(latest.Version, CurrentVersion),
|
|
changes = latest?.Changes.Select(c => new { sha = c.Sha, title = c.Title, date = c.Date }),
|
|
lastCheck = lastCheckUtc?.ToString("o"),
|
|
checkError,
|
|
canInstall = sdkInfo?.ok,
|
|
cannotInstall = sdkInfo is { ok: false } ? sdkInfo.Value.reason : null,
|
|
supervised = Supervised,
|
|
step = step.ToString().ToLowerInvariant(),
|
|
progress,
|
|
message,
|
|
error,
|
|
target,
|
|
startedBy,
|
|
stepSince = stepSinceUtc.ToString("o"),
|
|
log = log.ToArray()
|
|
};
|
|
}
|
|
}
|
|
|
|
public bool Busy
|
|
{
|
|
get { lock (gate) return step is Step.Downloading or Step.Building or Step.Installing or Step.Restarting; }
|
|
}
|
|
|
|
void SetStep(Step s, int percent, string text)
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (s != step) stepSinceUtc = DateTime.UtcNow;
|
|
step = s;
|
|
progress = percent;
|
|
message = text;
|
|
}
|
|
AddLog("== " + text);
|
|
}
|
|
|
|
void SetProgress(int percent, string text = null)
|
|
{
|
|
lock (gate)
|
|
{
|
|
progress = Math.Max(progress, Math.Clamp(percent, 0, 100));
|
|
if (text != null) message = text;
|
|
}
|
|
}
|
|
|
|
void AddLog(string line)
|
|
{
|
|
lock (gate)
|
|
{
|
|
log.Add(line);
|
|
if (log.Count > MaxLogLines) log.RemoveRange(0, log.Count - MaxLogLines);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Prüfen
|
|
|
|
/// <summary>Neueste Version auf dem Branch abfragen.</summary>
|
|
public async Task CheckAsync()
|
|
{
|
|
CheckSdk();
|
|
try
|
|
{
|
|
using var branchDoc = JsonDocument.Parse(await Http.GetStringAsync($"{Api}/branches/{Branch}"));
|
|
var commit = branchDoc.RootElement.GetProperty("commit");
|
|
string sha = commit.GetProperty("id").GetString();
|
|
string msg = commit.TryGetProperty("message", out var m) ? m.GetString() : "";
|
|
string date = commit.TryGetProperty("timestamp", out var t) ? t.GetString() : null;
|
|
|
|
string version = ParseCsprojVersion(await Http.GetStringAsync($"{Source}/raw/commit/{sha}/MSCO.Server.csproj"))
|
|
?? throw new Exception("No <Version> in MSCO.Server.csproj");
|
|
string modVersion = null;
|
|
try { modVersion = ParseModVersion(await Http.GetStringAsync($"{Source}/raw/commit/{sha}/GameServer.cs")); } catch { }
|
|
|
|
var found = new Latest
|
|
{
|
|
Version = version, Commit = sha, Date = date, ModVersion = modVersion,
|
|
Message = (msg ?? "").Split('\n')[0].Trim()
|
|
};
|
|
if (IsNewer(version, CurrentVersion))
|
|
{
|
|
try { found.Changes = await ChangesAsync(sha); } catch { }
|
|
}
|
|
|
|
bool newlyAvailable;
|
|
lock (gate)
|
|
{
|
|
newlyAvailable = IsNewer(version, CurrentVersion) && (latest == null || latest.Version != version || !notified);
|
|
latest = found;
|
|
lastCheckUtc = DateTime.UtcNow;
|
|
checkError = null;
|
|
if (newlyAvailable) notified = true;
|
|
}
|
|
if (newlyAvailable)
|
|
GameServer.Log($"Update available: MSCO server {version} (you have {CurrentVersion}) - install it in the web panel (Update)" +
|
|
(modVersion != null && modVersion != GameServer.ModVersion ? $". Players then need MSCO mod {modVersion}." : ""));
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
lock (gate)
|
|
{
|
|
lastCheckUtc = DateTime.UtcNow;
|
|
checkError = "Could not check for updates: " + Short(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Commits seit der eigenen Version (bzw. die letzten, wenn der eigene Commit unbekannt ist).</summary>
|
|
async Task<List<Commit>> ChangesAsync(string sha)
|
|
{
|
|
using var doc = JsonDocument.Parse(await Http.GetStringAsync(
|
|
$"{Api}/commits?sha={sha}&limit=30&stat=false&verification=false&files=false"));
|
|
var list = new List<Commit>();
|
|
string own = CurrentCommit;
|
|
foreach (var c in doc.RootElement.EnumerateArray())
|
|
{
|
|
string id = c.GetProperty("sha").GetString();
|
|
if (own != null && id.StartsWith(own, StringComparison.OrdinalIgnoreCase)) break;
|
|
var inner = c.GetProperty("commit");
|
|
string text = inner.GetProperty("message").GetString() ?? "";
|
|
string date = inner.TryGetProperty("author", out var a) && a.TryGetProperty("date", out var d) ? d.GetString() : null;
|
|
list.Add(new Commit { Sha = id[..Math.Min(8, id.Length)], Title = text.Split('\n')[0].Trim(), Date = date });
|
|
if (list.Count >= (own != null ? 30 : 10)) break;
|
|
}
|
|
return list;
|
|
}
|
|
|
|
/// <summary>Ist ein .NET SDK zum Bauen da? (Docker-Image ab 2.9 enthält es.)</summary>
|
|
void CheckSdk()
|
|
{
|
|
if (sdk != null) return;
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo(DotnetPath(), "--list-sdks") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
|
using var p = Process.Start(psi);
|
|
string output = p.StandardOutput.ReadToEnd();
|
|
p.WaitForExit(15000);
|
|
int need = Environment.Version.Major;
|
|
bool ok = output.Split('\n').Any(l => int.TryParse(l.Split('.')[0].Trim(), out int major) && major >= need);
|
|
lock (gate) sdk = ok ? (true, null) : (false, $"No .NET {need} SDK found - it is needed to build the update. " +
|
|
"With Docker: pull the new version once (git pull && docker compose up -d --build), its image contains the SDK.");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
lock (gate) sdk = (false, "The dotnet command was not found (" + Short(e) + ") - install the .NET SDK to update from the panel.");
|
|
}
|
|
}
|
|
|
|
static string DotnetPath()
|
|
{
|
|
string self = Environment.ProcessPath;
|
|
string name = Path.GetFileNameWithoutExtension(self ?? "");
|
|
return name.Equals("dotnet", StringComparison.OrdinalIgnoreCase) ? self : "dotnet";
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Installieren
|
|
|
|
/// <summary>Update im Hintergrund laden, bauen, installieren und neu starten. null = gestartet, sonst Fehlertext.</summary>
|
|
public string Install(string by)
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (step is Step.Downloading or Step.Building or Step.Installing or Step.Restarting) return "An update is already running";
|
|
if (latest == null || !IsNewer(latest.Version, CurrentVersion)) return "No update available - check again first";
|
|
if (sdk is { ok: false }) return sdk.Value.reason;
|
|
log.Clear();
|
|
error = null;
|
|
target = latest.Version;
|
|
startedBy = by;
|
|
step = Step.Downloading;
|
|
progress = 0;
|
|
stepSinceUtc = DateTime.UtcNow;
|
|
}
|
|
GameServer.Log($"Server update to {target} started by {by}");
|
|
_ = Task.Run(InstallAsync);
|
|
return null;
|
|
}
|
|
|
|
async Task InstallAsync()
|
|
{
|
|
try
|
|
{
|
|
// 1) Quellcode des neuesten Commits laden
|
|
SetStep(Step.Downloading, 2, "Checking the newest version");
|
|
await CheckAsync();
|
|
Latest want;
|
|
lock (gate) want = latest;
|
|
if (want == null || !IsNewer(want.Version, CurrentVersion)) throw new Exception(checkError ?? "No update available anymore");
|
|
lock (gate) target = want.Version;
|
|
|
|
Directory.CreateDirectory(WorkDir);
|
|
string zip = Path.Combine(WorkDir, "source.zip"), src = Path.Combine(WorkDir, "src");
|
|
SetStep(Step.Downloading, 5, $"Downloading MSCO server {want.Version} ({want.Commit[..8]})");
|
|
await DownloadAsync($"{Source}/archive/{want.Commit}.zip", zip);
|
|
if (Directory.Exists(src)) Directory.Delete(src, true);
|
|
ZipFile.ExtractToDirectory(zip, src);
|
|
string csproj = Directory.GetFiles(src, "MSCO.Server.csproj", SearchOption.AllDirectories)
|
|
.OrderBy(p => p.Length).FirstOrDefault()
|
|
?? throw new Exception("MSCO.Server.csproj not found in the download");
|
|
SetProgress(25, "Download complete");
|
|
|
|
// 2) Bauen
|
|
string build = Path.Combine(AppDir, "build");
|
|
if (Directory.Exists(build)) Directory.Delete(build, true);
|
|
SetStep(Step.Building, 27, "Building (dotnet publish) - this takes a minute");
|
|
await BuildAsync(csproj, build, want.Commit);
|
|
string dll = Path.Combine(build, "MSCO.Server.dll");
|
|
if (!File.Exists(dll)) throw new Exception("The build did not produce MSCO.Server.dll");
|
|
|
|
// 3) Installieren: build -> current (die bisherige bleibt als previous)
|
|
SetStep(Step.Installing, 92, "Installing");
|
|
WriteInfo(build, new InstallInfo { Version = want.Version, Commit = want.Commit, InstalledUtc = DateTime.UtcNow, By = startedBy });
|
|
string current = Path.Combine(AppDir, "current"), previous = Path.Combine(AppDir, "previous");
|
|
if (Directory.Exists(previous)) Directory.Delete(previous, true);
|
|
if (Directory.Exists(current)) Directory.Move(current, previous);
|
|
Directory.Move(build, current);
|
|
try { Directory.Delete(src, true); File.Delete(zip); } catch { }
|
|
AddLog("Installed to " + current);
|
|
|
|
// 4) Neu starten - die Spieler verbinden sich danach automatisch wieder
|
|
SetStep(Step.Restarting, 96, $"Restarting with version {want.Version}");
|
|
var server = host.Server;
|
|
if (server != null && host.State == HostState.Running)
|
|
{
|
|
// im Server-Thread: Spieler zählen und vorwarnen
|
|
var warn = server.Invoke(() => server.JoinedCount > 0
|
|
&& server.PanelSay("Server update - restarting in 5 seconds, you reconnect automatically", "Server") != null);
|
|
if (await Task.WhenAny(warn, Task.Delay(3000)) == warn && warn.Result) await Task.Delay(5000);
|
|
}
|
|
await Task.Delay(1500); // Panel: "Neustart" noch anzeigen lassen
|
|
GameServer.Log($"Update {want.Version} installed - restarting");
|
|
host.ExitForUpdate();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
lock (gate)
|
|
{
|
|
step = Step.Failed;
|
|
error = Short(e);
|
|
message = "Update failed";
|
|
stepSinceUtc = DateTime.UtcNow;
|
|
}
|
|
AddLog("FAILED: " + Short(e));
|
|
GameServer.Log("Server update failed: " + Short(e) + " - the server keeps running the old version");
|
|
}
|
|
}
|
|
|
|
async Task DownloadAsync(string url, string file)
|
|
{
|
|
using var res = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
|
|
res.EnsureSuccessStatusCode();
|
|
long? total = res.Content.Headers.ContentLength;
|
|
await using var input = await res.Content.ReadAsStreamAsync();
|
|
await using var output = File.Create(file);
|
|
var buffer = new byte[81920];
|
|
long done = 0;
|
|
int read;
|
|
while ((read = await input.ReadAsync(buffer)) > 0)
|
|
{
|
|
await output.WriteAsync(buffer.AsMemory(0, read));
|
|
done += read;
|
|
string mb = (done / 1048576.0).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture);
|
|
if (total > 0) SetProgress(5 + (int)(18 * done / total.Value), $"Downloading … {mb} MB");
|
|
else SetProgress(Math.Min(22, 5 + (int)(done / 100_000)), $"Downloading … {mb} MB");
|
|
}
|
|
AddLog($"Downloaded {done / 1024} KB");
|
|
}
|
|
|
|
async Task BuildAsync(string csproj, string output, string commit)
|
|
{
|
|
var psi = new ProcessStartInfo(DotnetPath())
|
|
{
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
WorkingDirectory = Path.GetDirectoryName(csproj)
|
|
};
|
|
foreach (string a in new[] { "publish", csproj, "-c", "Release", "-o", output, "--nologo", "--disable-build-servers",
|
|
"-p:SourceRevisionId=" + commit, "-p:UseAppHost=" + (OperatingSystem.IsWindows() ? "true" : "false") })
|
|
psi.ArgumentList.Add(a);
|
|
// Alles Schreibbare in den Datenordner (in Docker ist HOME nur /tmp)
|
|
psi.Environment["DOTNET_CLI_HOME"] = Path.Combine(WorkDir, "home");
|
|
psi.Environment["NUGET_PACKAGES"] = Path.Combine(WorkDir, "nuget");
|
|
psi.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
|
|
psi.Environment["DOTNET_NOLOGO"] = "1";
|
|
psi.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1";
|
|
psi.Environment["MSBUILDDISABLENODEREUSE"] = "1";
|
|
|
|
using var p = new Process { StartInfo = psi };
|
|
void OnLine(string line)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line)) return;
|
|
AddLog(line.TrimEnd());
|
|
if (line.Contains("Restored ") || line.Contains("All projects are up-to-date")) SetProgress(45, "Building - packages restored");
|
|
else if (line.Contains(" -> ") && line.Contains(".dll")) SetProgress(80, "Building - compiled");
|
|
else if (line.Contains(" -> ")) SetProgress(90, "Building - published");
|
|
}
|
|
p.OutputDataReceived += (_, e) => { if (e.Data != null) OnLine(e.Data); };
|
|
p.ErrorDataReceived += (_, e) => { if (e.Data != null) OnLine(e.Data); };
|
|
p.Start();
|
|
p.BeginOutputReadLine();
|
|
p.BeginErrorReadLine();
|
|
|
|
// Fortschritt langsam weiterlaufen lassen, solange das Bauen dauert
|
|
var started = DateTime.UtcNow;
|
|
while (!p.HasExited)
|
|
{
|
|
await Task.Delay(1000);
|
|
if (DateTime.UtcNow - started > BuildTimeout)
|
|
{
|
|
try { p.Kill(true); } catch { }
|
|
throw new Exception("The build took longer than " + BuildTimeout.TotalMinutes + " minutes");
|
|
}
|
|
lock (gate) if (progress < 88) progress++;
|
|
}
|
|
p.WaitForExit();
|
|
if (p.ExitCode != 0)
|
|
{
|
|
string lastError;
|
|
lock (gate) lastError = log.LastOrDefault(l => l.Contains("error", StringComparison.OrdinalIgnoreCase)) ?? "see the log";
|
|
throw new Exception($"The build failed (exit code {p.ExitCode}): {lastError}");
|
|
}
|
|
SetProgress(91, "Build complete");
|
|
}
|
|
|
|
static string Short(Exception e)
|
|
{
|
|
while (e.InnerException != null && e is AggregateException or TargetInvocationException) e = e.InnerException;
|
|
return e.Message;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Neu starten
|
|
|
|
/// <summary>Läuft das Programm unter Docker/systemd (die starten nach dem Beenden neu)?</summary>
|
|
public static bool Supervised =>
|
|
Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"
|
|
|| Environment.GetEnvironmentVariable("INVOCATION_ID") != null // systemd
|
|
|| Environment.GetEnvironmentVariable("MSCO_SUPERVISED") == "1";
|
|
|
|
/// <summary>Nach dem Beenden für ein Update: ohne Docker/systemd startet sich das Programm selbst neu.</summary>
|
|
public static void Relaunch(string[] args)
|
|
{
|
|
if (Supervised)
|
|
{
|
|
GameServer.Log("Exiting for the update - Docker/systemd starts the server again");
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
string self = Environment.ProcessPath;
|
|
var all = new List<string>();
|
|
if (Path.GetFileNameWithoutExtension(self).Equals("dotnet", StringComparison.OrdinalIgnoreCase))
|
|
all.Add(Environment.GetCommandLineArgs()[0]); // die gestartete MSCO.Server.dll
|
|
all.AddRange(args);
|
|
var psi = new ProcessStartInfo(self)
|
|
{
|
|
Arguments = string.Join(" ", all.Select(Quote)),
|
|
WorkingDirectory = Environment.CurrentDirectory,
|
|
UseShellExecute = OperatingSystem.IsWindows() // Windows: neues Konsolenfenster
|
|
};
|
|
Process.Start(psi);
|
|
GameServer.Log("Started the updated server");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
GameServer.Log("Could not restart automatically (" + e.Message + ") - please start the server again");
|
|
}
|
|
}
|
|
|
|
static string Quote(string a) => a.Length > 0 && a.IndexOfAny(new[] { ' ', '"', '\t' }) < 0 ? a : "\"" + a.Replace("\"", "\\\"") + "\"";
|
|
|
|
// ---------------------------------------------------------------- installierte Version starten
|
|
|
|
sealed class InstallInfo
|
|
{
|
|
public string Version { get; set; }
|
|
public string Commit { get; set; }
|
|
public DateTime InstalledUtc { get; set; }
|
|
public string By { get; set; }
|
|
public int Attempts { get; set; }
|
|
public bool Verified { get; set; }
|
|
}
|
|
|
|
static InstallInfo ReadInfo(string dir)
|
|
{
|
|
try { return JsonSerializer.Deserialize<InstallInfo>(File.ReadAllText(Path.Combine(dir, "msco-update.json"))); }
|
|
catch { return null; }
|
|
}
|
|
|
|
static void WriteInfo(string dir, InstallInfo info) =>
|
|
File.WriteAllText(Path.Combine(dir, "msco-update.json"), JsonSerializer.Serialize(info, new JsonSerializerOptions { WriteIndented = true }));
|
|
|
|
/// <summary>Läuft gerade eine installierte Update-Version? Dann nach einer Minute als funktionierend merken.</summary>
|
|
static void MarkHealthy()
|
|
{
|
|
string dir = Environment.GetEnvironmentVariable("MSCO_UPDATE_DIR");
|
|
if (dir == null) return;
|
|
var info = ReadInfo(dir);
|
|
if (info == null || info.Verified) return;
|
|
info.Verified = true;
|
|
info.Attempts = 0;
|
|
try { WriteInfo(dir, info); } catch { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ganz am Anfang von Program: liegt in DATA/app/current eine neuere Version, diese statt der eigenen ausführen
|
|
/// (im selben Prozess, eigener AssemblyLoadContext - Signale, Konsole und Docker bleiben gleich). Gibt den Exit-Code
|
|
/// zurück oder null, wenn die eigene Version laufen soll.
|
|
/// </summary>
|
|
public static int? RunInstalled(string[] args, string dataDir)
|
|
{
|
|
if (Environment.GetEnvironmentVariable("MSCO_UPDATE_DIR") != null) return null; // wir SIND die installierte Version
|
|
if (Environment.GetEnvironmentVariable("MSCO_NO_UPDATES") == "1") return null;
|
|
string dir = Path.Combine(dataDir, "app", "current");
|
|
var info = ReadInfo(dir);
|
|
if (info == null) return null;
|
|
string dll = Path.Combine(dir, "MSCO.Server.dll");
|
|
if (!File.Exists(dll)) return null;
|
|
if (!IsNewer(info.Version, CurrentVersion))
|
|
{
|
|
// Das Programm selbst ist gleich neu oder neuer (z.B. neues Docker-Image) - altes Update aufräumen
|
|
Console.WriteLine($"Installed update {info.Version} is not newer than this version ({CurrentVersion}) - removing it");
|
|
try { Directory.Delete(Path.Combine(dataDir, "app"), true); } catch { }
|
|
return null;
|
|
}
|
|
if (!info.Verified && info.Attempts >= MaxStartAttempts)
|
|
{
|
|
Console.WriteLine($"The installed update {info.Version} failed to start {info.Attempts} times - running the built-in version {CurrentVersion}");
|
|
try
|
|
{
|
|
string failed = Path.Combine(dataDir, "app", "failed");
|
|
if (Directory.Exists(failed)) Directory.Delete(failed, true);
|
|
Directory.Move(dir, failed);
|
|
}
|
|
catch { }
|
|
return null;
|
|
}
|
|
if (!info.Verified)
|
|
{
|
|
info.Attempts++;
|
|
try { WriteInfo(dir, info); } catch { }
|
|
}
|
|
|
|
MethodInfo entry;
|
|
try
|
|
{
|
|
var context = new UpdateLoadContext(dll);
|
|
entry = context.LoadFromAssemblyPath(dll).EntryPoint ?? throw new Exception("no entry point");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Console.WriteLine($"Could not load the installed update {info.Version}: {e.Message} - running the built-in version {CurrentVersion}");
|
|
return null;
|
|
}
|
|
Console.WriteLine($"Starting installed update {info.Version} (built-in version {CurrentVersion})");
|
|
Environment.SetEnvironmentVariable("MSCO_UPDATE_DIR", dir);
|
|
try
|
|
{
|
|
object result = entry.Invoke(null, entry.GetParameters().Length == 0 ? null : new object[] { args });
|
|
if (result is Task<int> task) return task.GetAwaiter().GetResult();
|
|
if (result is Task plain) { plain.GetAwaiter().GetResult(); return 0; }
|
|
return result is int code ? code : 0;
|
|
}
|
|
catch (TargetInvocationException e) when (e.InnerException != null)
|
|
{
|
|
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>Lädt die installierte Version samt ihren Abhängigkeiten (z.B. SharpCompress) aus ihrem Ordner.</summary>
|
|
sealed class UpdateLoadContext : AssemblyLoadContext
|
|
{
|
|
readonly AssemblyDependencyResolver resolver;
|
|
|
|
public UpdateLoadContext(string mainAssembly) : base("msco-update") => resolver = new AssemblyDependencyResolver(mainAssembly);
|
|
|
|
protected override Assembly Load(AssemblyName name)
|
|
{
|
|
string path = resolver.ResolveAssemblyToPath(name);
|
|
return path != null ? LoadFromAssemblyPath(path) : null; // Framework (ASP.NET ...) aus dem Standard-Kontext
|
|
}
|
|
|
|
protected override IntPtr LoadUnmanagedDll(string name)
|
|
{
|
|
string path = resolver.ResolveUnmanagedDllToPath(name);
|
|
return path != null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero;
|
|
}
|
|
}
|
|
}
|