Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Rule packs (support dates and newest CU per release) are published in a signed feed and hot-loaded without a restart; the verified pack is reloaded at startup. New versions are announced (Notify) or downloaded and staged by the unprivileged agent (Automatic), and installed by a privileged apply-update task that re-verifies signature and hash in an admin-only folder, keeps appsettings/web.config, health-checks the services and rolls back on failure. Stable and preview channels, manifest expiry and rollback protection, https or folder/share feeds for offline sites. Vendor tool gains rulepack and publish; the Hub can serve the feed; the web UI gets a System page with update, rule pack, license and exactly what is sent to the Hub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
226 lines
10 KiB
C#
226 lines
10 KiB
C#
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using SQLInsight.Rules.Lifecycle;
|
|
using SQLInsight.Updates;
|
|
|
|
namespace SQLInsight.Agent.Updates;
|
|
|
|
/// <summary>
|
|
/// Command line:
|
|
/// <list type="bullet">
|
|
/// <item><c>update check</c>: show what the feed offers.</item>
|
|
/// <item><c>update apply [--feed <url|mappe>]</c>: download and install the newest version now (administrator).</item>
|
|
/// <item><c>apply-update</c>: for the scheduled update task; installs a staged version when the mode is Automatic and
|
|
/// the maintenance window is open.</item>
|
|
/// </list>
|
|
/// Installing runs from a copy of the program in the updater's folder, so no file in the installation is in use.
|
|
/// </summary>
|
|
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<int> 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<int> 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;
|
|
}
|
|
|
|
/// <summary>Administrator: download and install now, regardless of mode and maintenance window.</summary>
|
|
private static async Task<int> 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);
|
|
}
|
|
|
|
/// <summary>Scheduled task: only in Automatic mode, only in the maintenance window, only if something is staged.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies the program to the updater's folder and runs <c>apply-update --runner</c> 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.
|
|
/// </summary>
|
|
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<int> 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 <url|mappe>.");
|
|
}
|
|
|
|
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<UpdateOptions>() ?? 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 <url|mappe>] Hent og installér nyeste version nu (administrator)
|
|
SQLInsight.Agent apply-update Til den planlagte opdateringsopgave
|
|
""");
|
|
return 1;
|
|
}
|
|
}
|