using System.Diagnostics; using System.Globalization; using SQLInsight.Rules.Lifecycle; using SQLInsight.Updates; namespace SQLInsight.Agent.Updates; /// /// Command line: /// /// update check: show what the feed offers. /// update apply [--feed <url|mappe>]: download and install the newest version now (administrator). /// apply-update: for the scheduled update task; installs a staged version when the mode is Automatic and /// the maintenance window is open. /// /// Installing runs from a copy of the program in the updater's folder, so no file in the installation is in use. /// public static class UpdateCommands { private const string RunnerFlag = "--runner"; public static bool Handles(string[] args) => args.FirstOrDefault() is "update" or "apply-update"; public static async Task RunAsync(string[] args) { ArgumentNullException.ThrowIfNull(args); var agentFolder = Option(args, "--install-dir") ?? AppContext.BaseDirectory; var settings = LoadOptions(agentFolder); if (Option(args, "--feed") is { } feed) { settings.Feed = feed; } using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; try { return (args.ElementAtOrDefault(0), args.ElementAtOrDefault(1)) switch { ("update", "check") => await CheckAsync(settings, http).ConfigureAwait(false), ("update", "apply") => await ApplyNowAsync(args, settings, agentFolder, http).ConfigureAwait(false), ("apply-update", _) when args.Contains(RunnerFlag) => await RunApplierAsync(args, settings, agentFolder).ConfigureAwait(false), ("apply-update", _) => ScheduledApply(args, settings, agentFolder), _ => Usage(), }; } catch (Exception ex) when (ex is UpdateException or IOException or UnauthorizedAccessException or FormatException) { Console.Error.WriteLine(ex.Message); return 2; } } private static async Task CheckAsync(UpdateOptions settings, HttpClient http) { var checker = Checker(settings, http); var manifest = (await checker.FetchAsync(CancellationToken.None).ConfigureAwait(false)).Manifest; var installed = AppVersion.Running; var pack = RulePack.Embedded(); Console.WriteLine($"Feed: {settings.Feed} ({settings.Channel}, udgivet {manifest.PublishedAt:yyyy-MM-dd HH:mm} UTC)"); Console.WriteLine($"Installeret: {installed}"); Console.WriteLine($"Tilgængelig: {manifest.Release?.Version ?? "-"}{(UpdateChecker.NewerRelease(manifest, installed) is { Security: true } ? " (sikkerhedsopdatering)" : string.Empty)}"); Console.WriteLine($"Regelpakke: {manifest.RulePack?.Version ?? "-"} (indbygget {pack.Version})"); return 0; } /// Administrator: download and install now, regardless of mode and maintenance window. private static async Task ApplyNowAsync(string[] args, UpdateOptions settings, string agentFolder, HttpClient http) { var checker = Checker(settings, http); var manifest = await checker.FetchAsync(CancellationToken.None).ConfigureAwait(false); if (!await checker.StageAsync(manifest, AppVersion.Running, CancellationToken.None).ConfigureAwait(false)) { Console.WriteLine($"Version {AppVersion.Running} er den nyeste på {settings.Channel}-kanalen."); return 0; } Console.WriteLine($"Version {manifest.Manifest.Release!.Version} er hentet og verificeret."); return LaunchRunner(args, settings, agentFolder); } /// Scheduled task: only in Automatic mode, only in the maintenance window, only if something is staged. private static int ScheduledApply(string[] args, UpdateOptions settings, string agentFolder) { if (settings.Application != AppUpdateMode.Automatic) { Console.WriteLine($"Automatisk installation er slået fra (Application = {settings.Application})."); return 0; } if (!settings.InMaintenanceWindow(TimeOnly.FromDateTime(DateTime.Now))) { Console.WriteLine($"Uden for vedligeholdelsesvinduet {settings.MaintenanceWindow}."); return 0; } if (!File.Exists(Path.Combine(settings.Directories.Pending, UpdateFeed.ManifestFile))) { Console.WriteLine("Ingen opdatering venter."); return 0; } return LaunchRunner(args, settings, agentFolder); } /// /// Copies the program to the updater's folder and runs apply-update --runner from there. On Windows the /// installation's files are locked while this process runs, so it does not wait; on Linux it waits, so a systemd /// oneshot unit does not kill the updater when it exits. /// private static int LaunchRunner(string[] args, UpdateOptions settings, string agentFolder) { var runner = Path.Combine(settings.UpdaterRoot(agentFolder), "runner"); if (Directory.Exists(runner)) { Directory.Delete(runner, recursive: true); } foreach (var file in Directory.EnumerateFiles(agentFolder, "*", SearchOption.AllDirectories)) { var target = Path.Combine(runner, Path.GetRelativePath(agentFolder, file)); Directory.CreateDirectory(Path.GetDirectoryName(target)!); File.Copy(file, target); } var host = Environment.ProcessPath ?? throw new InvalidOperationException("Programmets sti er ukendt."); var viaDotnet = Path.GetFileNameWithoutExtension(host).Equals("dotnet", StringComparison.OrdinalIgnoreCase); var start = new ProcessStartInfo(viaDotnet ? host : Path.Combine(runner, Path.GetFileName(host))) { UseShellExecute = false }; if (viaDotnet) { start.ArgumentList.Add(Path.Combine(runner, "SQLInsight.Agent.dll")); } string[] runnerArgs = ["apply-update", RunnerFlag, "--install-dir", agentFolder, "--wait-pid", Environment.ProcessId.ToString(CultureInfo.InvariantCulture)]; foreach (var arg in runnerArgs.Concat(Option(args, "--feed") is { } feed ? ["--feed", feed] : [])) { start.ArgumentList.Add(arg); } using var process = Process.Start(start) ?? throw new UpdateException("Opdateringen kunne ikke startes."); if (OperatingSystem.IsWindows()) { Console.WriteLine($"Opdateringen kører nu i baggrunden. Resultatet skrives i {settings.Directories.ResultFile} og vises i webbrugerfladen."); return 0; } process.WaitForExit(); return process.ExitCode; } private static async Task RunApplierAsync(string[] args, UpdateOptions settings, string agentFolder) { // On Windows the process that started us holds the installation's files open until it exits. if (OperatingSystem.IsWindows() && int.TryParse(Option(args, "--wait-pid"), NumberStyles.None, CultureInfo.InvariantCulture, out var parent)) { try { using var launcher = Process.GetProcessById(parent); using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1)); await launcher.WaitForExitAsync(timeout.Token).ConfigureAwait(false); } catch (Exception ex) when (ex is ArgumentException or OperationCanceledException) { // Already gone, or still running after a minute: go ahead, a locked file triggers a rollback. } } var root = settings.UpdaterRoot(agentFolder); var logFile = Path.Combine(root, "update.log"); void Log(string line) { var stamped = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss} {line}"; Console.WriteLine(stamped); File.AppendAllText(logFile, stamped + Environment.NewLine); } var applier = new UpdateApplier( new UpdateApplierOptions { Channel = settings.Channel, PrivateRoot = Path.Combine(root, "work"), Components = settings.ComponentsFor(agentFolder), HealthCheckDuration = settings.HealthCheckDuration, }, settings.Directories, settings.Keys, ServiceControl.ForCurrentOs(), TimeProvider.System, Log); var result = await applier.ApplyAsync(AppVersion.Running, CancellationToken.None).ConfigureAwait(false); return result.Outcome is UpdateOutcome.Applied or UpdateOutcome.NothingToDo ? 0 : 2; } private static UpdateChecker Checker(UpdateOptions settings, HttpClient http) { if (string.IsNullOrWhiteSpace(settings.Feed)) { throw new UpdateException("Der er intet opdateringsfeed. Angiv SQLInsight:Updates:Feed eller --feed ."); } return new UpdateChecker(new UpdateFeed(settings.Feed, http), settings.Keys, settings.Directories, settings.Channel, TimeProvider.System); } private static UpdateOptions LoadOptions(string agentFolder) { var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"; var configuration = new ConfigurationBuilder() .SetBasePath(agentFolder) .AddJsonFile("appsettings.json", optional: true) .AddJsonFile($"appsettings.{environment}.json", optional: true) .AddEnvironmentVariables() .Build(); return configuration.GetSection(UpdateOptions.SectionName).Get() ?? new UpdateOptions(); } private static string? Option(string[] args, string name) { var i = Array.IndexOf(args, name); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; } private static int Usage() { Console.Error.WriteLine(""" SQLInsight.Agent update check Vis hvad opdateringsfeedet tilbyder SQLInsight.Agent update apply [--feed ] Hent og installér nyeste version nu (administrator) SQLInsight.Agent apply-update Til den planlagte opdateringsopgave """); return 1; } }