Some checks are pending
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 / Build og unit tests (push) Waiting to run
CI / MSI-installer (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
installer/SQLInsight.wxs (WiX 3 syntax) builds on Linux with wixl (installer/build-msi.sh) and in Forgejo CI on every v* tag. Agent and Web are installed self-contained as Windows services under virtual service accounts (or a gMSA), with a settings page in the wizard or MSI properties for silent installs; the values go to sqlinsight.ini, read after appsettings.json. The data folder is locked to SYSTEM, Administrators and the agent account, the event log source is registered, the web port is opened in the firewall, and the automatic update task is an optional feature. Upgrades happen in place, keep edited settings and the chosen features; uninstall keeps the settings. msi-tables.py writes what wixl lacks (component and feature conditions, Permanent/NeverOverwrite, vital services, REG_EXPAND_SZ) and verify-msi.py checks the result (sequencing, deferred actions, ASCII only). test-msi-wine.sh runs install, upgrade and uninstall under Wine. Installations made with the MSI get new versions as MSI packages run by the update task, so Windows Installer stays in charge of the files. The example instance is removed from the shipped appsettings.json, and the CLI writes UTF-8 to the console. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
227 lines
10 KiB
C#
227 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)
|
|
.AddIniFile(Path.Combine(agentFolder, InstallerSettings.FileName), 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;
|
|
}
|
|
}
|