Fase 3: signed updates of rule packs and the application
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
This commit is contained in:
Claude 2026-09-29 09:19:41 +00:00
parent 7088efff52
commit 3121b598f9
No known key found for this signature in database
40 changed files with 2739 additions and 75 deletions

View file

@ -13,6 +13,8 @@
<Company>SEOSoft</Company>
<Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright>
<!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. -->
<Version>1.0.0</Version>
</PropertyGroup>
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">

View file

@ -19,6 +19,7 @@
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />

View file

@ -7,6 +7,7 @@
<Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" />
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
<Project Path="src/SQLInsight.Licensing/SQLInsight.Licensing.csproj" />
<Project Path="src/SQLInsight.Updates/SQLInsight.Updates.csproj" />
<Project Path="src/SQLInsight.Notifications/SQLInsight.Notifications.csproj" />
<Project Path="src/SQLInsight.Repository/SQLInsight.Repository.csproj" />
<Project Path="src/SQLInsight.Rules/SQLInsight.Rules.csproj" />
@ -21,6 +22,7 @@
<Project Path="tests/SQLInsight.Licensing.Tests/SQLInsight.Licensing.Tests.csproj" />
<Project Path="tests/SQLInsight.Notifications.Tests/SQLInsight.Notifications.Tests.csproj" />
<Project Path="tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj" />
<Project Path="tests/SQLInsight.Updates.Tests/SQLInsight.Updates.Tests.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj" />

View file

@ -129,4 +129,4 @@ Begge programmer understøtter systemd (`Type=notify`). Windows-login på Linux
## Opdatering
Stop tjenesten (eller app poolen), kopiér de nye filer ind uden at overskrive `appsettings.json`, og start igen. Agenten opgraderer repository-skemaet selv.
Se [opdatering.md](opdatering.md): regelpakken opdateres automatisk, og nye versioner kan installeres med `SQLInsight.Agent update apply` eller automatisk af en planlagt opgave. Manuelt: stop tjenesten (eller app poolen), kopiér de nye filer ind uden at overskrive `appsettings.json`, og start igen. Agenten opgraderer repository-skemaet selv.

137
docs/opdatering.md Normal file
View file

@ -0,0 +1,137 @@
# Opdateringer
SQLInsight Pro holder sig selv opdateret på to måder:
| Hvad | Indhold | Hvordan |
|---|---|---|
| **Regelpakken** | Microsofts supportdatoer og seneste CU/sikkerhedsopdatering pr. SQL Server-version | Hentes og tages i brug automatisk, uden genstart. Det er data, ikke kode. |
| **Programmet** | Agent og webbrugerflade | Tilstanden `Off`, `Notify` eller `Automatic`. Installeres af en privilegeret opdateringsopgave med backup og automatisk tilbagerulning. |
Begge kommer fra et **opdateringsfeed**: en https-adresse eller en mappe/et fildrev. Feedet behøver ikke at være betroet. Alt i det er signeret af SEOSoft (ECDSA P-256), og hver fil er låst af sin SHA-256 i det signerede manifest.
## Opsætning
I agentens `appsettings.json`:
```json
"SQLInsight": {
"Updates": {
"Feed": "https://hub.example.com/updates/",
"Channel": "stable",
"RulePacks": true,
"Application": "Notify",
"CheckInterval": "06:00:00",
"MaintenanceWindow": "02:00-05:00"
}
}
```
| Indstilling | Betydning |
|---|---|
| `Feed` | https-adresse, lokal mappe eller UNC-sti (`\\filserver\sqlinsight-feed`). Tom: ingen opdateringstjek. |
| `Channel` | `stable` (anbefalet i produktion) eller `preview` (nye regler og funktioner før alle andre). |
| `RulePacks` | Hent nyere regelpakker automatisk. Standard: til. |
| `Application` | `Off`: aldrig. `Notify`: vis i webbrugerfladen, at en ny version findes; en administrator installerer. `Automatic`: agenten henter og verificerer; opdateringsopgaven installerer i vedligeholdelsesvinduet. |
| `MaintenanceWindow` | Lokal tid for automatisk installation, fx `02:00-05:00`. Tom: når som helst. |
| `Components` | Hvad der opdateres. Standard: agenten selv. Se [Web på samme server](#web-på-samme-server). |
| `DataDirectory` | Agentens datamappe. Standard `%ProgramData%\SQLInsight` eller `/var/lib/sqlinsight`. |
Status for opdateringer, regelpakke og licens ses under **System** i webbrugerfladen.
## Installation af en ny version
### Manuelt (`Notify`)
Som administrator på agentens server:
```powershell
& "C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" update check
& "C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" update apply
```
`update apply` henter pakken, verificerer den, stopper tjenesten, tager backup, kopierer de nye filer ind (uden at røre `appsettings*.json` og `web.config`), starter tjenesten og holder øje med den i et minut. Stopper den, rulles der automatisk tilbage.
### Automatisk (`Automatic`)
Agenten kører som en konto med få rettigheder og kan ikke selv udskifte programfiler. Det gør en planlagt opgave, der kører som SYSTEM/root, og som selv verificerer signatur og hash igen, før noget installeres.
Windows:
```powershell
schtasks /Create /TN "SQLInsight Update" /SC HOURLY /RU SYSTEM /RL HIGHEST `
/TR "\"C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe\" apply-update"
```
Linux (`/etc/systemd/system/sqlinsight-update.service` og `.timer`):
```ini
[Unit]
Description=SQLInsight Pro: installér hentede opdateringer
[Service]
Type=oneshot
ExecStart=/opt/sqlinsight/agent/SQLInsight.Agent apply-update
```
```ini
[Unit]
Description=SQLInsight Pro: kig efter hentede opdateringer hver time
[Timer]
OnCalendar=hourly
RandomizedDelaySec=10m
[Install]
WantedBy=timers.target
```
```bash
sudo systemctl enable --now sqlinsight-update.timer
```
Agentens systemd-enhed skal have `StateDirectory=sqlinsight`, så den kan skrive i `/var/lib/sqlinsight`.
### Web på samme server
Skal webbrugerfladen opdateres sammen med agenten, angives begge komponenter:
```json
"Components": [
{ "Name": "Agent", "Path": "C:\\Program Files\\SQLInsight\\Agent", "Service": "SQLInsight Agent" },
{ "Name": "Web", "Path": "C:\\Program Files\\SQLInsight\\Web", "Service": "SQLInsight Web" }
]
```
Kører web i IIS, angives `"AppOffline": true` i stedet for `Service`. Så lægges `app_offline.htm` i mappen, mens filerne udskiftes.
## Uden internetadgang
`Feed` kan pege på en mappe eller et fildrev. Kopiér feed-mappen fra SEOSoft (eller fra en server med internetadgang) derhen. Signaturen kontrolleres på samme måde. Et manifest udløber efter 90 dage, så et gammelt feed ikke kan genbruges i det uendelige. Hent et nyt, inden det udløber.
## Sikkerhed
- Kun manifester signeret med en nøgle i `trusted-keys.json` (indbygget) eller `SQLInsight:Updates:TrustedKeys` accepteres.
- Hver fil (regelpakke og programpakke) er låst af størrelse og SHA-256 i manifestet. En ændret fil bliver aldrig brugt.
- Ingen nedgradering. Et manifest, der er ældre end et allerede set, afvises, og en version, der ikke er nyere end den installerede, installeres ikke.
- Opdateringsopgaven stoler ikke på agentens datamappe. Den kopierer pakken til sin egen mappe (`.sqlinsight-update` ved siden af installationen, kun skrivbar for administratorer), verificerer den der og installerer derfra.
- Den gemte regelpakke verificeres igen ved hver start. Fejler den, bruges den indbyggede.
- Den seneste backup ligger i `.sqlinsight-update\work\backup` indtil næste opdatering. Log: `.sqlinsight-update\update.log`.
## For SEOSoft: udgiv en opdatering
```bash
# Regelpakke ud fra katalogfilerne i src/SQLInsight.Rules/Lifecycle
dotnet run --project tools/SQLInsight.LicenseTool -- rulepack --version 2026.10.14 --out rulepack-2026.10.14.json
# Programpakker: én zip pr. platform med mapperne Agent/ og Web/
dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained false -p:Version=1.1.0 -o pkg/win-x64/Agent
dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained false -p:Version=1.1.0 -o pkg/win-x64/Web
# Signér kanalens manifest (det, der ikke angives, overtages fra det eksisterende manifest)
dotnet run --project tools/SQLInsight.LicenseTool -- publish --key sqlinsight-k20260929.private.pem --kid k20260929 \
--feed feed --channel stable --rulepack rulepack-2026.10.14.json \
--release 1.1.0 --asset win-x64=sqlinsight-1.1.0-win-x64.zip --asset linux-x64=sqlinsight-1.1.0-linux-x64.zip \
--notes https://git.seosoft.dk/seo/sqlinsight-pro/releases
```
Udgiv først på `preview`, og flyt til `stable`, når versionen har kørt et stykke tid. Feed-mappen kan serveres af SQLInsight Hub (`SQLInsight:Hub:UpdateFeedPath`, adressen bliver `https://hub…/updates/`) eller af enhver webserver.

View file

@ -1,12 +1,18 @@
using Microsoft.Extensions.Options;
using SQLInsight.Agent;
using SQLInsight.Agent.Monitoring;
using SQLInsight.Agent.Updates;
using SQLInsight.Connectivity;
using SQLInsight.Notifications;
using SQLInsight.Repository;
using SQLInsight.Rules;
using SQLInsight.Rules.Engine;
if (UpdateCommands.Handles(args))
{
return await UpdateCommands.RunAsync(args);
}
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Agent");
@ -15,7 +21,10 @@ builder.Services.AddSystemd();
builder.Services.Configure<AgentOptions>(builder.Configuration.GetSection(AgentOptions.SectionName));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
builder.Services.AddSingleton(new RuleEngine(BuiltInRules.Create()));
builder.Services.Configure<UpdateOptions>(builder.Configuration.GetSection(UpdateOptions.SectionName));
builder.Services.AddSingleton(sp => UpdateService.LoadKnowledge(
sp.GetRequiredService<IOptions<UpdateOptions>>().Value, sp.GetRequiredService<ILoggerFactory>().CreateLogger<UpdateService>()));
builder.Services.AddSingleton(sp => new RuleEngine(BuiltInRules.Create(sp.GetRequiredService<SQLInsight.Rules.Lifecycle.RuleKnowledge>())));
builder.Services.AddSingleton(sp =>
{
var connectionString = sp.GetRequiredService<IOptions<AgentOptions>>().Value.Repository.ConnectionString;
@ -55,7 +64,10 @@ builder.Services.Configure<SQLInsight.Agent.Hub.HubOptions>(builder.Configuratio
builder.Services.AddHttpClient("hub", c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddSingleton<SQLInsight.Agent.Hub.HubOutbox>();
builder.Services.AddHostedService<SQLInsight.Agent.Hub.HubSyncService>();
builder.Services.AddHttpClient("updates", c => c.Timeout = TimeSpan.FromMinutes(10));
builder.Services.AddHostedService<UpdateService>();
builder.Services.AddHostedService<MonitoringService>();
var host = builder.Build();
host.Run();
await host.RunAsync();
return 0;

View file

@ -12,6 +12,7 @@
<ProjectReference Include="..\SQLInsight.Notifications\SQLInsight.Notifications.csproj" />
<ProjectReference Include="..\SQLInsight.Contracts\SQLInsight.Contracts.csproj" />
<ProjectReference Include="..\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
<ProjectReference Include="..\SQLInsight.Updates\SQLInsight.Updates.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />

View file

@ -0,0 +1,226 @@
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 &lt;url|mappe&gt;]</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;
}
}

View file

@ -0,0 +1,89 @@
using System.Globalization;
using SQLInsight.Updates;
namespace SQLInsight.Agent.Updates;
public enum AppUpdateMode
{
/// <summary>Never download or install new versions (rule packs are controlled separately).</summary>
Off,
/// <summary>Show that a new version exists; an administrator installs it with <c>SQLInsight.Agent update apply</c>.</summary>
Notify,
/// <summary>Download new versions; the scheduled update task installs them in the maintenance window.</summary>
Automatic,
}
/// <summary>Updates of the rule pack and of SQLInsight Pro itself, from a signed feed.</summary>
public sealed class UpdateOptions
{
public const string SectionName = "SQLInsight:Updates";
/// <summary>
/// Where updates are published: an https URL, or a folder/file share for installations without internet access.
/// Empty: no update checks.
/// </summary>
public string? Feed { get; set; }
/// <summary>stable or preview.</summary>
public string Channel { get; set; } = UpdateManifest.StableChannel;
/// <summary>Load newer rule packs (support dates and the newest CU per version) automatically. They are data, not code.</summary>
public bool RulePacks { get; set; } = true;
public AppUpdateMode Application { get; set; } = AppUpdateMode.Notify;
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromHours(6);
/// <summary>Local time window for automatic installation, e.g. "02:00-05:00". Empty: any time.</summary>
public string? MaintenanceWindow { get; set; }
/// <summary>The agent's data folder. Default %ProgramData%\SQLInsight or /var/lib/sqlinsight.</summary>
public string? DataDirectory { get; set; }
/// <summary>
/// The updater's own folder: the running copy of the updater, the verified package and the backup. Must be writable
/// by administrators only. Default: .sqlinsight-update next to the agent's installation folder.
/// </summary>
public string? UpdaterDirectory { get; set; }
public TimeSpan HealthCheckDuration { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>What to update. Default: the agent itself. Add Web to update the web UI on the same server.</summary>
public IList<UpdateComponent> Components { get; } = [];
/// <summary>Additional public keys trusted for update manifests (key id → base64 SubjectPublicKeyInfo).</summary>
public IDictionary<string, string> TrustedKeys { get; } = new Dictionary<string, string>();
public UpdateDirectories Directories => new(DataDirectory ?? UpdateDirectories.DefaultRoot);
public Licensing.TrustedKeys Keys => Licensing.TrustedKeys.Embedded().With(TrustedKeys);
public static string DefaultAgentService => OperatingSystem.IsWindows() ? "SQLInsight Agent" : "sqlinsight-agent";
public IReadOnlyList<UpdateComponent> ComponentsFor(string agentFolder) => Components.Count > 0
? [.. Components]
: [new UpdateComponent { Name = "Agent", Path = agentFolder, Service = DefaultAgentService }];
public string UpdaterRoot(string agentFolder) =>
UpdaterDirectory ?? Path.Combine(Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(agentFolder))!, ".sqlinsight-update");
public bool InMaintenanceWindow(TimeOnly localTime)
{
if (string.IsNullOrWhiteSpace(MaintenanceWindow))
{
return true;
}
var parts = MaintenanceWindow.Split('-', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2
|| !TimeOnly.TryParseExact(parts[0], "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from)
|| !TimeOnly.TryParseExact(parts[1], "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to))
{
throw new FormatException($"MaintenanceWindow '{MaintenanceWindow}' skal have formen HH:mm-HH:mm, fx 02:00-05:00.");
}
return localTime.IsBetween(from, to);
}
}

View file

@ -0,0 +1,141 @@
using Microsoft.Extensions.Options;
using SQLInsight.Repository;
using SQLInsight.Rules.Lifecycle;
using SQLInsight.Updates;
namespace SQLInsight.Agent.Updates;
/// <summary>
/// Checks the feed on a schedule: loads newer rule packs straight away, and records (Notify) or downloads (Automatic)
/// new versions of SQLInsight Pro. Installing is left to the privileged updater (<c>apply-update</c>).
/// </summary>
public sealed partial class UpdateService(
IOptions<UpdateOptions> options,
RuleKnowledge knowledge,
InsightRepository repository,
IHttpClientFactory httpClientFactory,
TimeProvider time,
ILogger<UpdateService> logger) : BackgroundService
{
/// <summary>Give the monitoring service time to create the repository schema first.</summary>
public static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(StartupDelay, time, stoppingToken).ConfigureAwait(false);
using var timer = new PeriodicTimer(options.Value.CheckInterval, time);
do
{
try
{
await CheckOnceAsync(stoppingToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogCheckFailed(logger, ex);
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
public async Task<UpdateStatusRow> CheckOnceAsync(CancellationToken cancellationToken)
{
var settings = options.Value;
var directories = settings.Directories;
var installed = AppVersion.Running;
var last = directories.LastResult();
string? error = null;
AppRelease? available = null;
string? staged = null;
DateTime? checkedAt = null;
if (!string.IsNullOrWhiteSpace(settings.Feed))
{
try
{
var checker = new UpdateChecker(new UpdateFeed(settings.Feed, httpClientFactory.CreateClient("updates")), settings.Keys, directories, settings.Channel, time);
var manifest = await checker.FetchAsync(cancellationToken).ConfigureAwait(false);
checkedAt = time.GetUtcNow().UtcDateTime;
if (settings.RulePacks && await checker.UpdateRulePackAsync(manifest, knowledge.Current, cancellationToken).ConfigureAwait(false) is { } pack)
{
var previous = knowledge.Current.Version;
knowledge.Replace(pack);
LogRulePackLoaded(logger, pack.Version, previous);
}
available = UpdateChecker.NewerRelease(manifest.Manifest, installed);
if (available is not null)
{
LogReleaseAvailable(logger, available.Version, installed, settings.Application);
if (settings.Application == AppUpdateMode.Automatic && await checker.StageAsync(manifest, installed, cancellationToken).ConfigureAwait(false))
{
staged = available.Version;
}
}
}
catch (Exception ex) when (ex is UpdateException or IOException or UnauthorizedAccessException or HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
error = ex.Message;
LogCheckError(logger, ex.Message);
}
}
var status = new UpdateStatusRow
{
Feed = settings.Feed,
Channel = settings.Channel,
Mode = settings.Application.ToString(),
RulePacks = settings.RulePacks,
InstalledVersion = installed.ToString(),
RulePackVersion = knowledge.Current.Version.ToString(),
RulePackSource = knowledge.Current.Source,
AvailableVersion = available?.Version,
AvailableSecurity = available?.Security ?? false,
AvailableNotesUrl = available?.NotesUrl,
StagedVersion = staged,
LastCheckAt = checkedAt,
LastError = error,
LastResultOutcome = last?.Outcome.ToString(),
LastResultMessage = last?.Message,
LastResultAt = last?.At.UtcDateTime,
UpdatedAt = time.GetUtcNow().UtcDateTime,
};
await repository.SaveUpdateStatusAsync(status, cancellationToken).ConfigureAwait(false);
return status;
}
/// <summary>The rule pack at startup: a verified downloaded one if it is newer than the one compiled in.</summary>
public static RuleKnowledge LoadKnowledge(UpdateOptions settings, ILogger logger)
{
ArgumentNullException.ThrowIfNull(settings);
var knowledge = RuleKnowledge.Embedded();
try
{
if (new RulePackStore(settings.Directories).Load(settings.Keys) is { } stored && stored.Version > knowledge.Current.Version)
{
knowledge.Replace(stored);
}
}
catch (Exception ex) when (ex is UpdateException or IOException or UnauthorizedAccessException)
{
LogStoredPackRejected(logger, ex.Message);
}
return knowledge;
}
[LoggerMessage(Level = LogLevel.Information, Message = "Regelpakke {Version} indlæst (før {Previous}).")]
private static partial void LogRulePackLoaded(ILogger logger, Version version, Version previous);
[LoggerMessage(Level = LogLevel.Information, Message = "SQLInsight Pro {Available} er tilgængelig (installeret: {Installed}, tilstand: {Mode}).")]
private static partial void LogReleaseAvailable(ILogger logger, string available, AppVersion installed, AppUpdateMode mode);
[LoggerMessage(Level = LogLevel.Warning, Message = "Opdateringstjek fejlede: {Message}")]
private static partial void LogCheckError(ILogger logger, string message);
[LoggerMessage(Level = LogLevel.Error, Message = "Opdateringstjek fejlede uventet.")]
private static partial void LogCheckFailed(ILogger logger, Exception exception);
[LoggerMessage(Level = LogLevel.Warning, Message = "Den gemte regelpakke bruges ikke: {Message}. Den indbyggede bruges i stedet.")]
private static partial void LogStoredPackRejected(ILogger logger, string message);
}

View file

@ -15,4 +15,10 @@ public sealed class HubServerOptions
/// <summary>Portal login attempts allowed per client IP per minute.</summary>
public int LoginAttemptsPerMinute { get; set; } = 10;
/// <summary>
/// Folder with the signed update feed (made with the vendor tool's publish command), served at /updates/.
/// Empty: the hub does not serve updates.
/// </summary>
public string? UpdateFeedPath { get; set; }
}

View file

@ -82,6 +82,20 @@ public static class HubProgram
app.UseRateLimiter();
app.UseAntiforgery();
app.MapStaticAssets();
if (app.Configuration.GetSection(HubServerOptions.SectionName).Get<HubServerOptions>()?.UpdateFeedPath is { Length: > 0 } feed)
{
// Everything in the feed is signed or pinned by hash in a signed manifest; the hub only has to serve it.
var types = new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider();
types.Mappings[".sqli"] = "text/plain";
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(Path.GetFullPath(feed)),
RequestPath = "/updates",
ContentTypeProvider = types,
ServeUnknownFileTypes = false,
});
}
app.MapGet("/", () => Results.Redirect("/portal"));
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
FindingsEndpoint.Map(app);

View file

@ -0,0 +1,52 @@
using Dapper;
namespace SQLInsight.Repository;
public sealed record UpdateStatusRow
{
public string? Feed { get; init; }
public string Channel { get; init; } = "stable";
public string Mode { get; init; } = "Notify";
public bool RulePacks { get; init; }
public string InstalledVersion { get; init; } = string.Empty;
public string RulePackVersion { get; init; } = string.Empty;
public string RulePackSource { get; init; } = string.Empty;
public string? AvailableVersion { get; init; }
public bool AvailableSecurity { get; init; }
public string? AvailableNotesUrl { get; init; }
public string? StagedVersion { get; init; }
public DateTime? LastCheckAt { get; init; }
public string? LastError { get; init; }
public string? LastResultOutcome { get; init; }
public string? LastResultMessage { get; init; }
public DateTime? LastResultAt { get; init; }
public DateTime UpdatedAt { get; init; }
}
public sealed partial class InsightRepository
{
public async Task SaveUpdateStatusAsync(UpdateStatusRow status, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(status);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
MERGE insight.UpdateStatus AS t
USING (SELECT 1 AS Id) AS s ON t.Id = s.Id
WHEN MATCHED THEN UPDATE SET Feed = @Feed, Channel = @Channel, Mode = @Mode, RulePacks = @RulePacks, InstalledVersion = @InstalledVersion,
RulePackVersion = @RulePackVersion, RulePackSource = @RulePackSource, AvailableVersion = @AvailableVersion,
AvailableSecurity = @AvailableSecurity, AvailableNotesUrl = @AvailableNotesUrl, StagedVersion = @StagedVersion,
LastCheckAt = @LastCheckAt, LastError = @LastError, LastResultOutcome = @LastResultOutcome,
LastResultMessage = @LastResultMessage, LastResultAt = @LastResultAt, UpdatedAt = @UpdatedAt
WHEN NOT MATCHED THEN INSERT (Id, Feed, Channel, Mode, RulePacks, InstalledVersion, RulePackVersion, RulePackSource, AvailableVersion,
AvailableSecurity, AvailableNotesUrl, StagedVersion, LastCheckAt, LastError, LastResultOutcome, LastResultMessage, LastResultAt, UpdatedAt)
VALUES (1, @Feed, @Channel, @Mode, @RulePacks, @InstalledVersion, @RulePackVersion, @RulePackSource, @AvailableVersion,
@AvailableSecurity, @AvailableNotesUrl, @StagedVersion, @LastCheckAt, @LastError, @LastResultOutcome, @LastResultMessage, @LastResultAt, @UpdatedAt);
""", status).ConfigureAwait(false);
}
public async Task<UpdateStatusRow?> GetUpdateStatusAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<UpdateStatusRow>("SELECT * FROM insight.UpdateStatus WHERE Id = 1;").ConfigureAwait(false);
}
}

View file

@ -0,0 +1,22 @@
/* What the agent last knew about updates, for the web UI. One row. */
CREATE TABLE insight.UpdateStatus
(
Id tinyint NOT NULL CONSTRAINT PK_UpdateStatus PRIMARY KEY CONSTRAINT CK_UpdateStatus_Single CHECK (Id = 1),
Feed nvarchar(400) NULL,
Channel varchar(20) NOT NULL,
Mode varchar(20) NOT NULL,
RulePacks bit NOT NULL,
InstalledVersion varchar(50) NOT NULL,
RulePackVersion varchar(50) NOT NULL,
RulePackSource nvarchar(200) NOT NULL,
AvailableVersion varchar(50) NULL,
AvailableSecurity bit NOT NULL,
AvailableNotesUrl nvarchar(400) NULL,
StagedVersion varchar(50) NULL,
LastCheckAt datetime2(0) NULL,
LastError nvarchar(2000) NULL,
LastResultOutcome varchar(20) NULL,
LastResultMessage nvarchar(2000) NULL,
LastResultAt datetime2(0) NULL,
UpdatedAt datetime2(0) NOT NULL
);

View file

@ -11,68 +11,73 @@ namespace SQLInsight.Rules;
public static class BuiltInRules
{
public static IReadOnlyList<IRule> Create() =>
[
// Version
new SupportLifecycleRule(SupportLifecycleCatalog.LoadEmbedded()),
new PatchLevelRule(BuildCatalog.LoadEmbedded(), SupportLifecycleCatalog.LoadEmbedded()),
/// <param name="knowledge">The rule pack behind the version rules; the embedded one if null.</param>
public static IReadOnlyList<IRule> Create(RuleKnowledge? knowledge = null)
{
knowledge ??= RuleKnowledge.Embedded();
return
[
// Version
new SupportLifecycleRule(knowledge),
new PatchLevelRule(knowledge),
// Configuration
new MaxServerMemoryDefaultRule(),
new MaxServerMemoryTooHighRule(),
new CostThresholdRule(),
new MaxDopRule(),
new OptimizeForAdHocRule(),
.. SimpleConfigurationRules.Create(),
new PendingConfigurationRule(),
new DeadlockTraceFlagRule(),
// Configuration
new MaxServerMemoryDefaultRule(),
new MaxServerMemoryTooHighRule(),
new CostThresholdRule(),
new MaxDopRule(),
new OptimizeForAdHocRule(),
.. SimpleConfigurationRules.Create(),
new PendingConfigurationRule(),
new DeadlockTraceFlagRule(),
// Databases
new DatabaseStateRule(),
.. SimpleDatabaseRules.Create(),
new CompatibilityLevelRule(),
new FullBackupRule(),
new LogBackupRule(),
new CheckDbRule(),
new SuspectPagesRule(),
new VirtualLogFileRule(),
new FileGrowthRule(),
new FileMaxSizeRule(),
new QueryStoreStateRule(),
new TempdbFilesRule(),
// Databases
new DatabaseStateRule(),
.. SimpleDatabaseRules.Create(),
new CompatibilityLevelRule(),
new FullBackupRule(),
new LogBackupRule(),
new CheckDbRule(),
new SuspectPagesRule(),
new VirtualLogFileRule(),
new FileGrowthRule(),
new FileMaxSizeRule(),
new QueryStoreStateRule(),
new TempdbFilesRule(),
// Performance
.. WaitRules.Create(),
new DataFileReadLatencyRule(),
new LogFileWriteLatencyRule(),
new CpuRule(),
new MemoryGrantsPendingRule(),
new AnomalyRule(),
new PlanRegressionRule(),
new FailedForcedPlanRule(),
new ParameterSensitivityRule(),
new ImplicitConversionRule(),
new NoJoinPredicateRule(),
new ScalarUdfRule(),
new SpillRule(),
new ExcessiveGrantRule(),
// Performance
.. WaitRules.Create(),
new DataFileReadLatencyRule(),
new LogFileWriteLatencyRule(),
new CpuRule(),
new MemoryGrantsPendingRule(),
new AnomalyRule(),
new PlanRegressionRule(),
new FailedForcedPlanRule(),
new ParameterSensitivityRule(),
new ImplicitConversionRule(),
new NoJoinPredicateRule(),
new ScalarUdfRule(),
new SpillRule(),
new ExcessiveGrantRule(),
// Indexes and statistics
new MissingIndexRule(),
new UnusedIndexRule(),
new DuplicateIndexRule(),
new ForwardedRecordsRule(),
new StaleStatisticsRule(),
new AutoStatisticsRule(),
// Indexes and statistics
new MissingIndexRule(),
new UnusedIndexRule(),
new DuplicateIndexRule(),
new ForwardedRecordsRule(),
new StaleStatisticsRule(),
new AutoStatisticsRule(),
// Availability and jobs
new AvailabilityGroupHealthRule(),
new AvailabilityGroupLagRule(),
new FailedJobRule(),
new AgentStoppedRule(),
// Availability and jobs
new AvailabilityGroupHealthRule(),
new AvailabilityGroupLagRule(),
new FailedJobRule(),
new AgentStoppedRule(),
// Concurrency
new BlockingRule(),
new DeadlockRule(),
];
// Concurrency
new BlockingRule(),
new DeadlockRule(),
];
}
}

View file

@ -23,10 +23,28 @@ public sealed class BuildCatalog(DateOnly asOf, IEnumerable<KnownBuild> builds)
{
using var stream = typeof(BuildCatalog).Assembly.GetManifestResourceStream(ResourceName)
?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing.");
var document = JsonSerializer.Deserialize<Document>(stream, JsonOptions)
?? throw new InvalidOperationException("The build catalog is empty.");
return new BuildCatalog(document.AsOf, document.Releases.Select(r =>
new KnownBuild(r.Release, SqlServerVersion.Parse(r.Build), r.Name, r.Kb, r.Released, r.Security)));
return From(JsonSerializer.Deserialize<Document>(stream, JsonOptions));
}
/// <summary>Reads the catalog section of a rule pack. Throws <see cref="InvalidDataException"/> if it is malformed.</summary>
public static BuildCatalog FromJson(JsonElement element) => From(element.Deserialize<Document>(JsonOptions));
private static BuildCatalog From(Document? document)
{
if (document?.Releases is not { Count: > 0 } releases)
{
throw new InvalidDataException("The build catalog is empty.");
}
try
{
return new BuildCatalog(document.AsOf, releases.Select(r =>
new KnownBuild(r.Release, SqlServerVersion.Parse(r.Build), r.Name, r.Kb, r.Released, r.Security)));
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
throw new InvalidDataException("The build catalog is invalid: " + ex.Message, ex);
}
}
private static readonly JsonSerializerOptions JsonOptions = new()

View file

@ -8,8 +8,13 @@ namespace SQLInsight.Rules.Lifecycle;
/// <summary>
/// Instance behind the newest known cumulative update, security update or service pack.
/// </summary>
public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog lifecycle) : Rule
public sealed class PatchLevelRule(RuleKnowledge knowledge) : Rule
{
public PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog lifecycle)
: this(new RuleKnowledge(new RulePack(RulePack.VersionOf(catalog.AsOf), "test", catalog, lifecycle)))
{
}
/// <summary>After this long the catalog itself is flagged as possibly outdated.</summary>
public static readonly TimeSpan CatalogMaxAge = TimeSpan.FromDays(120);
@ -21,6 +26,8 @@ public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog
{
ArgumentNullException.ThrowIfNull(context);
var instance = context.Instance;
var pack = knowledge.Current;
var catalog = pack.Builds;
var latest = catalog.Latest(instance.Release)
?? throw new Core.Collection.DataUnavailableException($"Regelpakken kender ikke seneste build for {instance.Release.DisplayName()}. Opdater SQLInsight Pro.");
@ -30,7 +37,7 @@ public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog
}
var today = DateOnly.FromDateTime(context.Now.UtcDateTime);
var supported = lifecycle.Find(instance.Release)?.ExtendedEnd is { } end && today <= end;
var supported = pack.Lifecycle.Find(instance.Release)?.ExtendedEnd is { } end && today <= end;
var catalogAge = today.ToDateTime(TimeOnly.MinValue) - catalog.AsOf.ToDateTime(TimeOnly.MinValue);
var current = $"{instance.Version}" + (instance.ProductLevel is { } level ? $" ({level}{(instance.ProductUpdateLevel is { } cu ? " " + cu : string.Empty)})" : string.Empty);
@ -38,8 +45,8 @@ public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog
{
new("Installeret build", current),
new("Seneste kendte build", $"{latest.Build} ({latest.Name}, {latest.Kb}, {latest.Released.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)})"),
new("Katalog pr.", catalog.AsOf.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
+ (catalogAge > CatalogMaxAge ? " (forældet: opdater SQLInsight Pro for at se nyere opdateringer)" : string.Empty)),
new("Katalog pr.", catalog.AsOf.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + $" (regelpakke {pack.Version})"
+ (catalogAge > CatalogMaxAge ? " (forældet: opdater regelpakken eller SQLInsight Pro for at se nyere opdateringer)" : string.Empty)),
};
return RuleEvaluation.Found(CreateFinding(

View file

@ -0,0 +1,108 @@
using System.Globalization;
using System.Text.Json;
namespace SQLInsight.Rules.Lifecycle;
/// <summary>
/// The data behind the version rules: Microsoft's support dates and the newest build per release. It changes every
/// month, so it can be updated between releases of SQLInsight Pro through a signed rule pack.
/// </summary>
public sealed class RulePack
{
public const int FormatVersion = 1;
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
public RulePack(Version version, string source, BuildCatalog builds, SupportLifecycleCatalog lifecycle)
{
Version = version ?? throw new ArgumentNullException(nameof(version));
Source = source ?? throw new ArgumentNullException(nameof(source));
Builds = builds ?? throw new ArgumentNullException(nameof(builds));
Lifecycle = lifecycle ?? throw new ArgumentNullException(nameof(lifecycle));
}
public Version Version { get; }
/// <summary>Where the pack came from, for the UI ("indbygget", or the feed it was downloaded from).</summary>
public string Source { get; }
public BuildCatalog Builds { get; }
public SupportLifecycleCatalog Lifecycle { get; }
/// <summary>The pack compiled into this build. Its version is the build catalog date, e.g. 2026.9.29.</summary>
public static RulePack Embedded()
{
var builds = BuildCatalog.LoadEmbedded();
return new RulePack(VersionOf(builds.AsOf), "indbygget", builds, SupportLifecycleCatalog.LoadEmbedded());
}
public static Version VersionOf(DateOnly date) => new(date.Year, date.Month, date.Day);
/// <summary>
/// Parses a rule pack file: <c>{ "formatVersion": 1, "version": "2026.10.14", "buildCatalog": {…}, "supportLifecycle": {…} }</c>.
/// Throws <see cref="InvalidDataException"/> if anything is missing or malformed, so a broken pack is never loaded.
/// </summary>
public static RulePack Parse(ReadOnlySpan<byte> json, string source)
{
try
{
using var document = JsonDocument.Parse(json.ToArray());
var root = document.RootElement;
if (!root.TryGetProperty("formatVersion", out var format) || format.GetInt32() != FormatVersion)
{
throw new InvalidDataException($"Regelpakken har et formatVersion, som denne version ikke kender (forventet {FormatVersion}). Opdater SQLInsight Pro.");
}
if (!root.TryGetProperty("version", out var versionText) || !Version.TryParse(versionText.GetString(), out var version))
{
throw new InvalidDataException("Regelpakken mangler et gyldigt versionsnummer.");
}
if (!root.TryGetProperty("buildCatalog", out var builds) || !root.TryGetProperty("supportLifecycle", out var lifecycle))
{
throw new InvalidDataException("Regelpakken mangler buildCatalog eller supportLifecycle.");
}
return new RulePack(version, source, BuildCatalog.FromJson(builds), SupportLifecycleCatalog.FromJson(lifecycle));
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
{
throw new InvalidDataException("Regelpakken er ikke gyldig JSON: " + ex.Message, ex);
}
}
/// <summary>Builds a rule pack file from the two catalog files (vendor tool).</summary>
public static byte[] Create(Version version, ReadOnlySpan<byte> buildCatalogJson, ReadOnlySpan<byte> supportLifecycleJson)
{
ArgumentNullException.ThrowIfNull(version);
using var builds = JsonDocument.Parse(buildCatalogJson.ToArray());
using var lifecycle = JsonDocument.Parse(supportLifecycleJson.ToArray());
var bytes = JsonSerializer.SerializeToUtf8Bytes(new
{
formatVersion = FormatVersion,
version = version.ToString(),
createdAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
buildCatalog = builds.RootElement,
supportLifecycle = lifecycle.RootElement,
}, Indented);
// Refuse to publish a pack this version could not load.
Parse(bytes, "validering");
return bytes;
}
}
/// <summary>
/// The rule pack in use. Rules read <see cref="Current"/> on every evaluation, so a new pack takes effect without a restart.
/// </summary>
public sealed class RuleKnowledge(RulePack pack)
{
private volatile RulePack _current = pack ?? throw new ArgumentNullException(nameof(pack));
public RulePack Current => _current;
public static RuleKnowledge Embedded() => new(RulePack.Embedded());
public void Replace(RulePack pack) => _current = pack ?? throw new ArgumentNullException(nameof(pack));
}

View file

@ -31,9 +31,27 @@ public sealed class SupportLifecycleCatalog
{
using var stream = typeof(SupportLifecycleCatalog).Assembly.GetManifestResourceStream(ResourceName)
?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing.");
var document = JsonSerializer.Deserialize<CatalogDocument>(stream, JsonOptions)
?? throw new InvalidOperationException("The support lifecycle catalog is empty.");
return new SupportLifecycleCatalog(document.Releases);
return From(JsonSerializer.Deserialize<CatalogDocument>(stream, JsonOptions));
}
/// <summary>Reads the lifecycle section of a rule pack. Throws <see cref="InvalidDataException"/> if it is malformed.</summary>
public static SupportLifecycleCatalog FromJson(JsonElement element) => From(element.Deserialize<CatalogDocument>(JsonOptions));
private static SupportLifecycleCatalog From(CatalogDocument? document)
{
if (document?.Releases is not { Count: > 0 } releases)
{
throw new InvalidDataException("The support lifecycle catalog is empty.");
}
try
{
return new SupportLifecycleCatalog(releases);
}
catch (ArgumentException ex)
{
throw new InvalidDataException("The support lifecycle catalog is invalid: " + ex.Message, ex);
}
}
public SupportLifecycle? Find(SqlServerRelease release) => _entries.GetValueOrDefault(release);

View file

@ -9,8 +9,13 @@ namespace SQLInsight.Rules.Lifecycle;
/// Flags instances running a SQL Server release that is out of, or about to leave, Microsoft support.
/// Needs no capabilities, so it runs on every version from SQL Server 2000.
/// </summary>
public sealed class SupportLifecycleRule(SupportLifecycleCatalog catalog) : IRule
public sealed class SupportLifecycleRule(RuleKnowledge knowledge) : IRule
{
public SupportLifecycleRule(SupportLifecycleCatalog catalog)
: this(new RuleKnowledge(new RulePack(new Version(0, 0), "test", BuildCatalog.LoadEmbedded(), catalog)))
{
}
public const string RuleId = "SQLI-VER-001";
/// <summary>How long before end of extended support the finding escalates to High.</summary>
@ -34,7 +39,7 @@ public sealed class SupportLifecycleRule(SupportLifecycleCatalog catalog) : IRul
$"Versionen {context.Instance.Version} er ukendt for regelpakken. Opdater SQLInsight Pro.");
}
var lifecycle = catalog.Find(release);
var lifecycle = knowledge.Current.Lifecycle.Find(release);
if (lifecycle?.MainstreamEnd is not { } mainstreamEnd || lifecycle.ExtendedEnd is not { } extendedEnd)
{
return RuleEvaluation.NotMeasurable(

View file

@ -0,0 +1,90 @@
using System.Globalization;
using System.Reflection;
namespace SQLInsight.Updates;
/// <summary>A release version: <c>1.4.0</c> or a preview such as <c>1.5.0-preview.2</c>. A preview sorts before its release.</summary>
public sealed class AppVersion : IComparable<AppVersion>
{
private AppVersion(Version core, string? preRelease)
{
Core = core;
PreRelease = preRelease;
}
public Version Core { get; }
public string? PreRelease { get; }
public static AppVersion Parse(string text)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
var withoutBuild = text.Split('+')[0].Trim();
var dash = withoutBuild.IndexOf('-', StringComparison.Ordinal);
var core = dash < 0 ? withoutBuild : withoutBuild[..dash];
if (!Version.TryParse(core, out var version))
{
throw new FormatException($"'{text}' er ikke et gyldigt versionsnummer.");
}
return new AppVersion(Normalize(version), dash < 0 ? null : withoutBuild[(dash + 1)..]);
}
/// <summary>The version of the running SQLInsight Pro (from the informational version set at build time).</summary>
public static AppVersion Running => Parse(
Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0.0.0");
public int CompareTo(AppVersion? other)
{
if (other is null)
{
return 1;
}
var core = Core.CompareTo(other.Core);
if (core != 0)
{
return core;
}
return (PreRelease, other.PreRelease) switch
{
(null, null) => 0,
(null, _) => 1,
(_, null) => -1,
_ => ComparePreRelease(PreRelease!, other.PreRelease!),
};
}
public static bool operator ==(AppVersion? left, AppVersion? right) => left is null ? right is null : left.Equals(right);
public static bool operator !=(AppVersion? left, AppVersion? right) => !(left == right);
public static bool operator >(AppVersion left, AppVersion right) => left.CompareTo(right) > 0;
public static bool operator <(AppVersion left, AppVersion right) => left.CompareTo(right) < 0;
public static bool operator >=(AppVersion left, AppVersion right) => left.CompareTo(right) >= 0;
public static bool operator <=(AppVersion left, AppVersion right) => left.CompareTo(right) <= 0;
public override bool Equals(object? obj) => obj is AppVersion other && CompareTo(other) == 0;
public override int GetHashCode() => HashCode.Combine(Core, PreRelease);
public override string ToString() => PreRelease is null ? Core.ToString(3) : $"{Core.ToString(3)}-{PreRelease}";
private static Version Normalize(Version v) => new(v.Major, v.Minor, Math.Max(v.Build, 0));
/// <summary>SemVer: dot-separated identifiers, numeric ones compared as numbers.</summary>
private static int ComparePreRelease(string a, string b)
{
var x = a.Split('.');
var y = b.Split('.');
for (var i = 0; i < Math.Min(x.Length, y.Length); i++)
{
var bothNumeric = int.TryParse(x[i], NumberStyles.None, CultureInfo.InvariantCulture, out var nx) & int.TryParse(y[i], NumberStyles.None, CultureInfo.InvariantCulture, out var ny);
var c = bothNumeric ? nx.CompareTo(ny) : string.CompareOrdinal(x[i], y[i]);
if (c != 0)
{
return c;
}
}
return x.Length.CompareTo(y.Length);
}
}

View file

@ -0,0 +1,77 @@
using System.Runtime.InteropServices;
using SQLInsight.Licensing;
namespace SQLInsight.Updates;
public static class ManifestVerifier
{
/// <summary>
/// Verifies signature, channel and expiry. Throws <see cref="UpdateException"/> with a message for the operator.
/// </summary>
/// <param name="notBefore">The newest manifest accepted earlier; an older one is refused (rollback protection).</param>
public static UpdateManifest Verify(string token, TrustedKeys keys, string channel, DateTimeOffset now, DateTimeOffset? notBefore = null)
{
ArgumentNullException.ThrowIfNull(keys);
if (keys.Count == 0)
{
throw new UpdateException("Der er ingen betroede nøgler til at verificere opdateringer. Opdateringer er slået fra i denne build.");
}
var manifest = SignedDocument.Verify<UpdateManifest>(token, keys, m => m.KeyId)
?? throw new UpdateException("Opdateringsmanifestet har ikke en gyldig signatur fra SEOSoft. Det bliver ignoreret.");
if (!string.Equals(manifest.Channel, channel, StringComparison.OrdinalIgnoreCase))
{
throw new UpdateException($"Manifestet er til kanalen '{manifest.Channel}', men agenten følger '{channel}'.");
}
if (manifest.ExpiresAt < now)
{
throw new UpdateException($"Manifestet udløb {manifest.ExpiresAt:yyyy-MM-dd}. Feedet er ikke blevet opdateret; kontakt SEOSoft eller hent et nyt offline-feed.");
}
if (notBefore is { } seen && manifest.PublishedAt < seen)
{
throw new UpdateException($"Manifestet er ældre ({manifest.PublishedAt:yyyy-MM-dd HH:mm}) end et, der allerede er brugt ({seen:yyyy-MM-dd HH:mm}). Det bliver ignoreret.");
}
foreach (var file in Files(manifest))
{
FeedPath.Validate(file.Path);
}
return manifest;
}
private static IEnumerable<FileRef> Files(UpdateManifest manifest)
{
if (manifest.RulePack is { } pack)
{
yield return pack.File;
}
foreach (var asset in manifest.Release?.Assets ?? [])
{
yield return asset.File;
}
}
}
public static class Platform
{
/// <summary>The package platform for this machine, e.g. win-x64 or linux-arm64.</summary>
public static string Current =>
(OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsLinux() ? "linux" : "other") + "-" + RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();
}
public static class FeedPath
{
/// <summary>Feed paths are relative, forward-slash, and never leave the feed root.</summary>
public static void Validate(string path)
{
if (string.IsNullOrWhiteSpace(path) || path.StartsWith('/') || path.Contains('\\', StringComparison.Ordinal) || path.Contains(':', StringComparison.Ordinal)
|| path.Split('/').Any(segment => segment is "" or "." or ".."))
{
throw new UpdateException($"Manifestet indeholder en ugyldig sti: '{path}'.");
}
}
}

View file

@ -0,0 +1,45 @@
using System.Security.Cryptography;
using System.Text;
using SQLInsight.Licensing;
using SQLInsight.Rules.Lifecycle;
namespace SQLInsight.Updates;
/// <summary>
/// The downloaded rule pack on disk, next to the signed manifest that vouches for it. The signature and hash are checked
/// again every time it is loaded, so a changed file is never used.
/// </summary>
public sealed class RulePackStore(UpdateDirectories directories)
{
private string PackFile => Path.Combine(directories.RulePack, "pack.json");
private string ManifestFile => Path.Combine(directories.RulePack, "manifest.sqli");
public void Save(byte[] pack, string manifestToken)
{
UpdateDirectories.WriteAtomic(PackFile, pack);
UpdateDirectories.WriteAtomic(ManifestFile, Encoding.UTF8.GetBytes(manifestToken));
}
/// <summary>The stored pack, or null if there is none. Throws <see cref="UpdateException"/> if it fails verification.</summary>
public RulePack? Load(TrustedKeys keys)
{
if (!File.Exists(PackFile) || !File.Exists(ManifestFile))
{
return null;
}
var manifest = SignedDocument.Verify<UpdateManifest>(File.ReadAllText(ManifestFile), keys, m => m.KeyId)
?? throw new UpdateException("Den gemte regelpakkes manifest har ikke en gyldig signatur.");
var reference = manifest.RulePack ?? throw new UpdateException("Den gemte regelpakkes manifest nævner ingen regelpakke.");
var bytes = File.ReadAllBytes(PackFile);
UpdateFeed.Verify(reference.File, bytes.Length, SHA256.HashData(bytes));
try
{
return RulePack.Parse(bytes, $"regelpakke fra {manifest.Channel}-kanalen, {manifest.PublishedAt:yyyy-MM-dd}");
}
catch (InvalidDataException ex)
{
throw new UpdateException(ex.Message, ex);
}
}
}

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Updates</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
<ProjectReference Include="..\SQLInsight.Rules\SQLInsight.Rules.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.ServiceProcess.ServiceController" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,83 @@
using System.Diagnostics;
using System.Runtime.Versioning;
using System.ServiceProcess;
namespace SQLInsight.Updates;
/// <summary>Stops and starts the services being updated.</summary>
public interface IServiceControl
{
Task StopAsync(string service, CancellationToken cancellationToken);
Task StartAsync(string service, CancellationToken cancellationToken);
Task<bool> IsRunningAsync(string service, CancellationToken cancellationToken);
}
public static class ServiceControl
{
public static IServiceControl ForCurrentOs() =>
OperatingSystem.IsWindows() ? new WindowsServiceControl() : new SystemdServiceControl();
}
[SupportedOSPlatform("windows")]
public sealed class WindowsServiceControl : IServiceControl
{
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
public Task StopAsync(string service, CancellationToken cancellationToken) => Task.Run(() =>
{
using var controller = new ServiceController(service);
if (controller.Status != ServiceControllerStatus.Stopped)
{
controller.Stop();
controller.WaitForStatus(ServiceControllerStatus.Stopped, Timeout);
}
}, cancellationToken);
public Task StartAsync(string service, CancellationToken cancellationToken) => Task.Run(() =>
{
using var controller = new ServiceController(service);
if (controller.Status != ServiceControllerStatus.Running)
{
controller.Start();
controller.WaitForStatus(ServiceControllerStatus.Running, Timeout);
}
}, cancellationToken);
public Task<bool> IsRunningAsync(string service, CancellationToken cancellationToken) => Task.Run(() =>
{
using var controller = new ServiceController(service);
controller.Refresh();
return controller.Status == ServiceControllerStatus.Running;
}, cancellationToken);
}
public sealed class SystemdServiceControl : IServiceControl
{
public async Task StopAsync(string service, CancellationToken cancellationToken) =>
await RunAsync(["stop", service], throwOnError: true, cancellationToken).ConfigureAwait(false);
public async Task StartAsync(string service, CancellationToken cancellationToken) =>
await RunAsync(["start", service], throwOnError: true, cancellationToken).ConfigureAwait(false);
public async Task<bool> IsRunningAsync(string service, CancellationToken cancellationToken) =>
await RunAsync(["is-active", "--quiet", service], throwOnError: false, cancellationToken).ConfigureAwait(false) == 0;
private static async Task<int> RunAsync(string[] args, bool throwOnError, CancellationToken cancellationToken)
{
var start = new ProcessStartInfo("systemctl") { RedirectStandardError = true, UseShellExecute = false };
foreach (var arg in args)
{
start.ArgumentList.Add(arg);
}
using var process = Process.Start(start) ?? throw new UpdateException("systemctl kunne ikke startes.");
var error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (throwOnError && process.ExitCode != 0)
{
throw new UpdateException($"systemctl {string.Join(' ', args)} fejlede: {error.Trim()}");
}
return process.ExitCode;
}
}

View file

@ -0,0 +1,269 @@
using System.IO.Compression;
using System.Security.Cryptography;
using SQLInsight.Licensing;
namespace SQLInsight.Updates;
/// <summary>A program folder that is updated from the package folder with the same name.</summary>
public sealed class UpdateComponent
{
/// <summary>Folder name in the package: Agent or Web.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Installation folder.</summary>
public string Path { get; set; } = string.Empty;
/// <summary>Service to stop and start, if the component runs as a Windows service or systemd unit.</summary>
public string? Service { get; set; }
/// <summary>Hosted in IIS: take it offline with app_offline.htm while files are replaced.</summary>
public bool AppOffline { get; set; }
}
public sealed class UpdateApplierOptions
{
public required string Channel { get; init; }
/// <summary>
/// Working folder for the package, the extracted files and the backup. Must be writable by administrators only
/// (next to the installation), never the agent's data folder: files are verified here and then installed from here.
/// </summary>
public required string PrivateRoot { get; init; }
public required IReadOnlyList<UpdateComponent> Components { get; init; }
/// <summary>How long every restarted service must keep running before the update counts as good.</summary>
public TimeSpan HealthCheckDuration { get; init; } = TimeSpan.FromMinutes(1);
/// <summary>Files kept from the installation, never overwritten by the package.</summary>
public IReadOnlyList<string> PreservedFiles { get; init; } = ["appsettings.json", "appsettings.*.json", "web.config"];
}
/// <summary>
/// The privileged half of updating, run by an administrator or a scheduled task. It trusts nothing the agent staged:
/// the manifest signature, the package hash and the version are all checked again here. Every component is backed up
/// first; if a service does not come back, everything is restored.
/// </summary>
public sealed class UpdateApplier(UpdateApplierOptions options, UpdateDirectories directories, TrustedKeys keys, IServiceControl services, TimeProvider time, Action<string> log)
{
private const string AppOfflineFile = "app_offline.htm";
private string PrivatePackage => System.IO.Path.Combine(options.PrivateRoot, "package.zip");
/// <summary>The previous version, kept until the next update.</summary>
private string BackupRoot => System.IO.Path.Combine(options.PrivateRoot, "backup");
public async Task<UpdateResult> ApplyAsync(AppVersion installed, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(installed);
var result = await ApplyCoreAsync(installed, cancellationToken).ConfigureAwait(false);
if (result.Outcome != UpdateOutcome.NothingToDo)
{
directories.SaveResult(result);
}
log(result.Message);
return result;
}
private async Task<UpdateResult> ApplyCoreAsync(AppVersion installed, CancellationToken cancellationToken)
{
var manifestFile = System.IO.Path.Combine(directories.Pending, UpdateFeed.ManifestFile);
var package = System.IO.Path.Combine(directories.Pending, "package.zip");
if (!File.Exists(manifestFile) || !File.Exists(package))
{
return Result(UpdateOutcome.NothingToDo, installed, null, "Ingen opdatering venter.");
}
UpdateManifest manifest;
ReleaseAsset asset;
try
{
manifest = ManifestVerifier.Verify(await File.ReadAllTextAsync(manifestFile, cancellationToken).ConfigureAwait(false), keys, options.Channel, time.GetUtcNow());
if (UpdateChecker.NewerRelease(manifest, installed) is not { } release)
{
DiscardPending();
return Result(UpdateOutcome.NothingToDo, installed, null, $"Den ventende opdatering er ikke nyere end {installed}; den er fjernet.");
}
asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, Platform.Current, StringComparison.OrdinalIgnoreCase))
?? throw new UpdateException($"Version {release.Version} har ingen pakke til {Platform.Current}.");
// Copy first, then verify the copy: the pending folder is writable by the agent's account.
DeleteDirectory(options.PrivateRoot);
Directory.CreateDirectory(options.PrivateRoot);
File.Copy(package, PrivatePackage);
await using (var stream = File.OpenRead(PrivatePackage))
{
UpdateFeed.Verify(asset.File, stream.Length, await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
}
}
catch (UpdateException ex)
{
DiscardPending();
return Result(UpdateOutcome.Failed, installed, null, "Opdateringen blev afvist: " + ex.Message);
}
var target = manifest.Release!.Version;
var extracted = System.IO.Path.Combine(options.PrivateRoot, "files");
ZipFile.ExtractToDirectory(PrivatePackage, extracted);
var components = options.Components.Where(c => Directory.Exists(System.IO.Path.Combine(extracted, c.Name))).ToList();
if (components.Count == 0)
{
DiscardPending();
return Result(UpdateOutcome.Failed, installed, target, $"Pakken til {target} indeholder ingen af de installerede komponenter ({string.Join(", ", options.Components.Select(c => c.Name))}).");
}
log($"Opdaterer {string.Join(", ", components.Select(c => c.Name))} fra {installed} til {target}.");
try
{
await StopAsync(components, cancellationToken).ConfigureAwait(false);
foreach (var component in components)
{
var backup = System.IO.Path.Combine(BackupRoot, component.Name);
DeleteDirectory(backup);
CopyDirectory(component.Path, backup, _ => true);
}
foreach (var component in components)
{
CopyDirectory(System.IO.Path.Combine(extracted, component.Name), component.Path, relative => !IsPreserved(component.Path, relative));
}
await StartAsync(components, cancellationToken).ConfigureAwait(false);
if (await UnhealthyAsync(components, cancellationToken).ConfigureAwait(false) is { } failed)
{
throw new UpdateException($"{failed} kørte ikke stabilt efter opdateringen.");
}
}
catch (Exception ex) when (ex is UpdateException or IOException or UnauthorizedAccessException or InvalidOperationException or System.ComponentModel.Win32Exception)
{
log($"Opdateringen fejlede ({ex.Message}); ruller tilbage til {installed}.");
try
{
await StopAsync(components, cancellationToken).ConfigureAwait(false);
foreach (var component in components)
{
CopyDirectory(System.IO.Path.Combine(BackupRoot, component.Name), component.Path, _ => true);
}
await StartAsync(components, cancellationToken).ConfigureAwait(false);
}
catch (Exception rollback) when (rollback is UpdateException or IOException or UnauthorizedAccessException or InvalidOperationException or System.ComponentModel.Win32Exception)
{
// Not retried automatically: an administrator has to look at it.
DiscardPending();
return Result(UpdateOutcome.Failed, installed, target,
$"Opdateringen til {target} fejlede ({ex.Message}), og tilbagerulningen fejlede også ({rollback.Message}). Backup ligger i {BackupRoot}.");
}
DiscardPending();
return Result(UpdateOutcome.RolledBack, installed, target, $"Opdateringen til {target} fejlede ({ex.Message}) og blev rullet tilbage til {installed}.");
}
finally
{
DeleteDirectory(extracted);
File.Delete(PrivatePackage);
}
DiscardPending();
return Result(UpdateOutcome.Applied, installed, target, $"Opdateret fra {installed} til {target}.");
}
private async Task StopAsync(IEnumerable<UpdateComponent> components, CancellationToken cancellationToken)
{
foreach (var component in components)
{
if (component.AppOffline)
{
await File.WriteAllTextAsync(System.IO.Path.Combine(component.Path, AppOfflineFile),
"<!doctype html><title>Opdaterer</title><p>SQLInsight Pro opdateres. Prøv igen om et øjeblik.</p>", cancellationToken).ConfigureAwait(false);
}
if (component.Service is { Length: > 0 } service)
{
await services.StopAsync(service, cancellationToken).ConfigureAwait(false);
}
}
if (components.Any(c => c.AppOffline))
{
// IIS unloads the application when app_offline.htm appears; give it time to release the files.
await Task.Delay(TimeSpan.FromSeconds(5), time, cancellationToken).ConfigureAwait(false);
}
}
private async Task StartAsync(IEnumerable<UpdateComponent> components, CancellationToken cancellationToken)
{
foreach (var component in components)
{
if (component.Service is { Length: > 0 } service)
{
await services.StartAsync(service, cancellationToken).ConfigureAwait(false);
}
File.Delete(System.IO.Path.Combine(component.Path, AppOfflineFile));
}
}
/// <summary>Watches the services for <see cref="UpdateApplierOptions.HealthCheckDuration"/>; returns the first that stops.</summary>
private async Task<string?> UnhealthyAsync(IReadOnlyList<UpdateComponent> components, CancellationToken cancellationToken)
{
var watched = components.Where(c => c.Service is { Length: > 0 }).Select(c => c.Service!).ToList();
var until = time.GetUtcNow() + options.HealthCheckDuration;
do
{
foreach (var service in watched)
{
if (!await services.IsRunningAsync(service, cancellationToken).ConfigureAwait(false))
{
return service;
}
}
await Task.Delay(TimeSpan.FromMilliseconds(Math.Min(2000, Math.Max(10, options.HealthCheckDuration.TotalMilliseconds / 5))), time, cancellationToken).ConfigureAwait(false);
}
while (time.GetUtcNow() < until);
return null;
}
private bool IsPreserved(string installFolder, string relativePath) =>
!relativePath.Contains(System.IO.Path.DirectorySeparatorChar, StringComparison.Ordinal)
&& File.Exists(System.IO.Path.Combine(installFolder, relativePath))
&& options.PreservedFiles.Any(pattern => System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(pattern, relativePath, ignoreCase: true));
private void DiscardPending() => DeleteDirectory(directories.Pending);
private UpdateResult Result(UpdateOutcome outcome, AppVersion from, string? to, string message) =>
new(outcome, from.ToString(), to, message, time.GetUtcNow());
private static void CopyDirectory(string source, string destination, Func<string, bool> include)
{
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
{
var relative = System.IO.Path.GetRelativePath(source, file);
if (!include(relative))
{
continue;
}
var target = System.IO.Path.Combine(destination, relative);
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(target)!);
// Write beside the target and rename over it: replaces a program file that is still mapped by a running
// process on Linux (a plain overwrite fails with "text file busy"), and never leaves half a file behind.
var temporary = target + ".sqlinsight-new";
File.Copy(file, temporary, overwrite: true);
File.Move(temporary, target, overwrite: true);
}
}
private static void DeleteDirectory(string path)
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
}

View file

@ -0,0 +1,92 @@
using System.Text;
using SQLInsight.Licensing;
using SQLInsight.Rules.Lifecycle;
namespace SQLInsight.Updates;
public sealed record CheckedManifest(UpdateManifest Manifest, string Token);
/// <summary>
/// The unprivileged half of updating, run by the agent: reads the signed manifest, installs newer rule packs, and
/// downloads and stages application packages. It never replaces program files; <see cref="UpdateApplier"/> does that.
/// </summary>
public sealed class UpdateChecker(UpdateFeed feed, TrustedKeys keys, UpdateDirectories directories, string channel, TimeProvider time)
{
public string Channel { get; } = channel;
public async Task<CheckedManifest> FetchAsync(CancellationToken cancellationToken)
{
var token = await feed.ReadManifestAsync(Channel, cancellationToken).ConfigureAwait(false);
var manifest = ManifestVerifier.Verify(token, keys, Channel, time.GetUtcNow(), directories.LastManifest(Channel));
directories.RememberManifest(Channel, manifest.PublishedAt);
return new CheckedManifest(manifest, token);
}
/// <summary>Downloads the manifest's rule pack if it is newer than <paramref name="current"/>. Returns the new pack or null.</summary>
public async Task<RulePack?> UpdateRulePackAsync(CheckedManifest source, RulePack current, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(current);
if (source.Manifest.RulePack is not { } reference || !Version.TryParse(reference.Version, out var offered) || offered <= current.Version)
{
return null;
}
var bytes = await feed.DownloadAsync(reference.File, cancellationToken).ConfigureAwait(false);
RulePack pack;
try
{
pack = RulePack.Parse(bytes, $"regelpakke fra {source.Manifest.Channel}-kanalen, {source.Manifest.PublishedAt:yyyy-MM-dd}");
}
catch (InvalidDataException ex)
{
throw new UpdateException(ex.Message, ex);
}
if (pack.Version != offered)
{
throw new UpdateException($"Regelpakkens version ({pack.Version}) svarer ikke til manifestet ({offered}).");
}
new RulePackStore(directories).Save(bytes, source.Token);
return pack;
}
/// <summary>The release in the manifest if it is newer than the running version, otherwise null.</summary>
public static AppRelease? NewerRelease(UpdateManifest manifest, AppVersion installed)
{
ArgumentNullException.ThrowIfNull(manifest);
return manifest.Release is { } release && AppVersion.Parse(release.Version) > installed ? release : null;
}
/// <summary>
/// Downloads the package for this platform into the pending folder, where the privileged updater picks it up.
/// Returns false if there is nothing newer than <paramref name="installed"/>.
/// </summary>
public async Task<bool> StageAsync(CheckedManifest source, AppVersion installed, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(source);
if (NewerRelease(source.Manifest, installed) is not { } release)
{
return false;
}
var asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, Platform.Current, StringComparison.OrdinalIgnoreCase))
?? throw new UpdateException($"Version {release.Version} har ingen pakke til {Platform.Current}.");
var package = Path.Combine(directories.Pending, "package.zip");
var manifestFile = Path.Combine(directories.Pending, UpdateFeed.ManifestFile);
if (File.Exists(package) && File.Exists(manifestFile) && await File.ReadAllTextAsync(manifestFile, cancellationToken).ConfigureAwait(false) == source.Token)
{
return true;
}
Directory.CreateDirectory(directories.Pending);
File.Delete(manifestFile);
var download = package + ".download";
await feed.DownloadToFileAsync(asset.File, download, cancellationToken).ConfigureAwait(false);
File.Move(download, package, overwrite: true);
UpdateDirectories.WriteAtomic(manifestFile, Encoding.UTF8.GetBytes(source.Token));
return true;
}
}

View file

@ -0,0 +1,79 @@
using System.Text.Json;
namespace SQLInsight.Updates;
/// <summary>
/// Files the agent keeps between runs: the active rule pack, a staged application update, and the result of the last
/// update. Windows: %ProgramData%\SQLInsight; Linux: /var/lib/sqlinsight. The agent's service account can write here,
/// so the privileged updater treats everything in it as untrusted.
/// </summary>
public sealed class UpdateDirectories(string root)
{
internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
public string Root { get; } = root;
public string RulePack => Path.Combine(Root, "rulepack");
public string Pending => Path.Combine(Root, "updates", "pending");
public string ResultFile => Path.Combine(Root, "updates", "last-result.json");
public string StateFile => Path.Combine(Root, "updates", "state.json");
public static string DefaultRoot => OperatingSystem.IsWindows()
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "SQLInsight")
: "/var/lib/sqlinsight";
/// <summary>The newest manifest date accepted per channel (rollback protection).</summary>
public DateTimeOffset? LastManifest(string channel) =>
ReadJson<Dictionary<string, DateTimeOffset>>(StateFile) is { } state && state.TryGetValue(channel, out var at) ? at : null;
public void RememberManifest(string channel, DateTimeOffset publishedAt)
{
var state = ReadJson<Dictionary<string, DateTimeOffset>>(StateFile) ?? [];
if (!state.TryGetValue(channel, out var at) || at < publishedAt)
{
state[channel] = publishedAt;
WriteJson(StateFile, state);
}
}
public UpdateResult? LastResult() => ReadJson<UpdateResult>(ResultFile);
public void SaveResult(UpdateResult result) => WriteJson(ResultFile, result);
internal static T? ReadJson<T>(string path)
where T : class
{
try
{
return File.Exists(path) ? JsonSerializer.Deserialize<T>(File.ReadAllBytes(path), Json) : null;
}
catch (JsonException)
{
return null;
}
}
internal static void WriteJson<T>(string path, T value) => WriteAtomic(path, JsonSerializer.SerializeToUtf8Bytes(value, Json));
/// <summary>Writes to a temporary file and moves it into place, so a reader never sees half a file.</summary>
internal static void WriteAtomic(string path, byte[] bytes)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temp = path + ".tmp";
File.WriteAllBytes(temp, bytes);
File.Move(temp, path, overwrite: true);
}
}
public enum UpdateOutcome
{
NothingToDo,
Applied,
RolledBack,
Failed,
}
public sealed record UpdateResult(UpdateOutcome Outcome, string? FromVersion, string? ToVersion, string Message, DateTimeOffset At);

View file

@ -0,0 +1,151 @@
using System.Security.Cryptography;
namespace SQLInsight.Updates;
/// <summary>
/// Where updates come from: an HTTPS URL, or a local folder or file share (offline and air-gapped installations).
/// Layout: <c>&lt;channel&gt;/manifest.sqli</c> plus the files the manifests point to.
/// </summary>
public sealed class UpdateFeed
{
public const string ManifestFile = "manifest.sqli";
private const long MaxManifestBytes = 64 * 1024;
private readonly Uri? _baseUri;
private readonly string? _directory;
private readonly HttpClient? _http;
public UpdateFeed(string location, HttpClient? http)
{
ArgumentException.ThrowIfNullOrWhiteSpace(location);
if (Uri.TryCreate(location, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp))
{
if (uri.Scheme == Uri.UriSchemeHttp && !uri.IsLoopback)
{
throw new UpdateException("Opdateringsfeedet skal bruge https.");
}
_baseUri = uri.AbsoluteUri.EndsWith('/') ? uri : new Uri(uri.AbsoluteUri + "/");
_http = http ?? throw new ArgumentNullException(nameof(http));
}
else
{
_directory = uri is { IsFile: true } ? uri.LocalPath : location;
}
Location = location;
}
public string Location { get; }
public async Task<string> ReadManifestAsync(string channel, CancellationToken cancellationToken)
{
FeedPath.Validate(channel);
var bytes = await ReadAsync($"{channel}/{ManifestFile}", MaxManifestBytes, cancellationToken).ConfigureAwait(false);
return System.Text.Encoding.UTF8.GetString(bytes).Trim();
}
/// <summary>Reads a small file into memory and checks size and SHA-256 against the signed manifest.</summary>
public async Task<byte[]> DownloadAsync(FileRef file, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(file);
FeedPath.Validate(file.Path);
var bytes = await ReadAsync(file.Path, file.Size, cancellationToken).ConfigureAwait(false);
Verify(file, bytes.Length, SHA256.HashData(bytes));
return bytes;
}
/// <summary>Streams a package to <paramref name="destination"/>, checking size and SHA-256. The file is deleted if it does not match.</summary>
public async Task DownloadToFileAsync(FileRef file, string destination, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(file);
FeedPath.Validate(file.Path);
try
{
await using (var source = await OpenAsync(file.Path, cancellationToken).ConfigureAwait(false))
await using (var target = File.Create(destination))
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = new byte[81920];
long total = 0;
int read;
while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
total += read;
if (total > file.Size)
{
throw new UpdateException($"{file.Path} er større end angivet i manifestet.");
}
hash.AppendData(buffer, 0, read);
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
}
Verify(file, total, hash.GetHashAndReset());
}
}
catch
{
File.Delete(destination);
throw;
}
}
public static void Verify(FileRef file, long size, byte[] sha256)
{
ArgumentNullException.ThrowIfNull(file);
if (size != file.Size || !string.Equals(Convert.ToHexString(sha256), file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new UpdateException($"{file.Path} matcher ikke manifestets SHA-256. Filen er beskadiget eller ændret og bliver ikke brugt.");
}
}
private async Task<byte[]> ReadAsync(string path, long maxBytes, CancellationToken cancellationToken)
{
await using var stream = await OpenAsync(path, cancellationToken).ConfigureAwait(false);
using var buffer = new MemoryStream();
var chunk = new byte[16384];
int read;
while ((read = await stream.ReadAsync(chunk, cancellationToken).ConfigureAwait(false)) > 0)
{
if (buffer.Length + read > maxBytes)
{
throw new UpdateException($"{path} er større end tilladt.");
}
buffer.Write(chunk, 0, read);
}
return buffer.ToArray();
}
private async Task<Stream> OpenAsync(string path, CancellationToken cancellationToken)
{
if (_directory is not null)
{
var full = Path.GetFullPath(Path.Combine(_directory, path.Replace('/', Path.DirectorySeparatorChar)));
if (!File.Exists(full))
{
throw new UpdateException($"Filen {path} findes ikke i opdateringsfeedet {_directory}.");
}
return File.OpenRead(full);
}
try
{
var response = await _http!.GetAsync(new Uri(_baseUri!, path), HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
response.Dispose();
throw new UpdateException($"Opdateringsfeedet svarede {(int)response.StatusCode} for {path}.");
}
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
throw new UpdateException($"Opdateringsfeedet kunne ikke nås: {ex.Message}", ex);
}
}
}

View file

@ -0,0 +1,45 @@
namespace SQLInsight.Updates;
/// <summary>
/// What is published on a channel (stable or preview). Signed by the vendor as a <c>SQLI1.…</c> document; every file it
/// points to is pinned by SHA-256, so a feed server or file share only needs to be available, not trusted.
/// </summary>
public sealed record UpdateManifest
{
public const string StableChannel = "stable";
public const string PreviewChannel = "preview";
public required string KeyId { get; init; }
public required string Channel { get; init; }
public required DateTimeOffset PublishedAt { get; init; }
/// <summary>After this the manifest is refused, so an old manifest cannot be replayed forever to hide newer updates.</summary>
public required DateTimeOffset ExpiresAt { get; init; }
public RulePackRef? RulePack { get; init; }
public AppRelease? Release { get; init; }
}
/// <summary>A file in the feed. <see cref="Path"/> is relative to the feed root.</summary>
public sealed record FileRef(string Path, string Sha256, long Size);
public sealed record RulePackRef(string Version, FileRef File);
public sealed record AppRelease
{
public required string Version { get; init; }
public required DateOnly Released { get; init; }
/// <summary>Contains security fixes; shown prominently.</summary>
public bool Security { get; init; }
public string? NotesUrl { get; init; }
/// <summary>One package per platform, e.g. win-x64 and linux-x64.</summary>
public IReadOnlyList<ReleaseAsset> Assets { get; init; } = [];
}
/// <summary>A zip with one folder per component (Agent, Web), each the output of dotnet publish.</summary>
public sealed record ReleaseAsset(string Platform, FileRef File);
public sealed class UpdateException(string message, Exception? inner = null) : Exception(message, inner);

View file

@ -11,6 +11,7 @@
<span class="nav-label">Overvågning</span>
<NavLink href="/" Match="NavLinkMatch.All"><Icon Name="servers" /> Instanser</NavLink>
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
<span class="nav-label">Community</span>
<a href="https://git.seosoft.dk/seo/sqlinsight-pro" rel="noopener"><Icon Name="users" /> Projekt og forum</a>

View file

@ -0,0 +1,150 @@
@page "/system"
@using System.Text.Json
@inject InsightRepository Repository
@inject Display Display
<PageTitle>System · SQLInsight Pro</PageTitle>
<h1>System</h1>
<p class="lead">Version, opdateringer, regelpakke og hvad der sendes til SQLInsight Hub.</p>
<section class="panel">
<h2>Opdateringer</h2>
@if (_update is null)
{
<p class="muted">Agenten har ikke rapporteret opdateringsstatus endnu. Den tjekker første gang et halvt minut efter start.</p>
}
else
{
@if (_update.AvailableVersion is { } available)
{
<div class="alert @(_update.AvailableSecurity ? "" : "warn")">
<strong>SQLInsight Pro @available er tilgængelig@(_update.AvailableSecurity ? " og indeholder sikkerhedsrettelser" : "").</strong>
@if (_update.AvailableNotesUrl is { } notes) { <span> <a href="@notes" rel="noopener">Se ændringer</a>.</span> }
<p>
@switch (_update.Mode)
{
case "Automatic":
@(_update.StagedVersion is null ? "Den hentes ved næste tjek og installeres af opdateringsopgaven" : "Den er hentet og verificeret, og installeres af opdateringsopgaven")
@(" i vedligeholdelsesvinduet.")
break;
case "Notify":
<text>Installér den som administrator på agentens server med <code>SQLInsight.Agent update apply</code>.</text>
break;
default:
<text>Automatiske opdateringer er slået fra.</text>
break;
}
</p>
</div>
}
@if (_update.LastError is { } error)
{
<div class="alert warn"><strong>Seneste tjek fejlede.</strong><pre>@error</pre></div>
}
<table class="evidence">
<tr><th>Installeret version</th><td>@_update.InstalledVersion</td></tr>
<tr><th>Opdateringer af programmet</th><td>@ModeText(_update.Mode) · kanal @_update.Channel</td></tr>
<tr><th>Feed</th><td>@(_update.Feed ?? "ikke sat op (SQLInsight:Updates:Feed)")</td></tr>
<tr><th>Seneste tjek</th><td>@Display.Time(_update.LastCheckAt)</td></tr>
@if (_update.LastResultOutcome is { } outcome)
{
<tr><th>Seneste installation</th><td>@Display.Time(_update.LastResultAt): @_update.LastResultMessage</td></tr>
}
</table>
}
</section>
<section class="panel">
<h2>Regelpakke</h2>
@if (_update is null)
{
<p class="muted">Ukendt, indtil agenten har rapporteret.</p>
}
else
{
<p>Supportdatoer og seneste CU pr. SQL Server-version kommer fra regelpakken. Den opdateres uafhængigt af programmet, så nye CU'er kendes, så snart de er udgivet.</p>
<table class="evidence">
<tr><th>Version</th><td>@_update.RulePackVersion</td></tr>
<tr><th>Kilde</th><td>@_update.RulePackSource</td></tr>
<tr><th>Automatisk opdatering</th><td>@(_update.RulePacks ? "Til" : "Fra")</td></tr>
</table>
}
</section>
<section class="panel">
<h2>SQLInsight Hub og licens</h2>
@if (_hub is null)
{
<p class="muted">Agenten har ikke rapporteret licensstatus endnu.</p>
}
else
{
<table class="evidence">
<tr><th>Deling med SQLInsight Hub</th><td>@(_hub.Enabled ? $"Til ({_hub.HubUrl})" : "Fra: intet forlader netværket")</td></tr>
<tr><th>Licens</th><td>@_hub.LicenseMessage</td></tr>
@if (_hub.Customer is { } customer)
{
<tr><th>Kunde og aftale</th><td>@customer · @_hub.LicensePlan · udløber @Display.Time(_hub.LicenseExpires)</td></tr>
}
<tr><th>Følsomme målinger</th><td>@(_hub.SendSensitive ? "Sendes (forespørgselstekst, logins, værtsnavne)" : "Sendes ikke; erstattes af [udeladt]")</td></tr>
<tr><th>Senest sendt</th><td>@Display.Time(_hub.LastSentAt) · @_hub.Pending i kø</td></tr>
@if (_hub.LastError is { } hubError)
{
<tr><th>Seneste fejl</th><td>@Display.Time(_hub.LastErrorAt): @hubError</td></tr>
}
</table>
}
</section>
@if (_submissions.Count > 0)
{
<section class="panel">
<h2>Det sendes til SQLInsight Hub</h2>
<p class="muted">Præcis det indhold, agenten senest har sendt (eller har i kø) pr. instans.</p>
@foreach (var s in _submissions)
{
<details>
<summary>@s.InstanceName · @(s.SentAt is { } sent ? "sendt " + Display.Time(sent) : "i kø siden " + Display.Time(s.CreatedAt))</summary>
<div class="script"><pre>@Pretty(s.Payload)</pre></div>
</details>
}
</section>
}
@code {
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
private UpdateStatusRow? _update;
private HubStatusRow? _hub;
private IReadOnlyList<(string InstanceName, DateTime CreatedAt, DateTime? SentAt, string Payload)> _submissions = [];
protected override async Task OnInitializedAsync()
{
_update = await Repository.GetUpdateStatusAsync();
_hub = await Repository.GetHubStatusAsync();
_submissions = await Repository.GetLatestSubmissionsAsync();
}
private static string ModeText(string mode) => mode switch
{
"Automatic" => "Automatisk i vedligeholdelsesvinduet",
"Notify" => "Besked, installeres manuelt",
_ => "Fra",
};
private static string Pretty(string json)
{
try
{
using var document = JsonDocument.Parse(json);
return JsonSerializer.Serialize(document.RootElement, Indented);
}
catch (JsonException)
{
return json;
}
}
}

View file

@ -154,3 +154,6 @@ meter { width: 64px; height: 8px; margin-right: .4rem; vertical-align: middle; }
.page, .topbar { padding-left: 1rem; padding-right: 1rem; }
.grid2 { grid-template-columns: 1fr; }
}
details { border-top: 1px solid var(--border); padding: .6rem 0; }
details summary { cursor: pointer; font-weight: 500; }
details .script pre { max-height: 32rem; }

View file

@ -147,4 +147,24 @@ public class HubApiTests(HubFixture hub) : IClassFixture<HubFixture>
Assert.Equal(HttpStatusCode.BadRequest, (await PostAsync(license, new { schemaVersion = 99 })).StatusCode);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await PostAsync(license, badSeverity)).StatusCode);
}
[Fact]
public async Task Hub_serves_the_update_feed()
{
var feed = Path.Combine(Path.GetTempPath(), "hubfeed-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(feed, "stable"));
await File.WriteAllTextAsync(Path.Combine(feed, "stable", "manifest.sqli"), "SQLI1.x.y");
try
{
await using var factory = hub.Factory.WithWebHostBuilder(b => b.UseSetting("SQLInsight:Hub:UpdateFeedPath", feed));
var client = factory.CreateClient();
Assert.Equal("SQLI1.x.y", await client.GetStringAsync("/updates/stable/manifest.sqli"));
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/../HubOptions.cs")).StatusCode);
}
finally
{
Directory.Delete(feed, recursive: true);
}
}
}

View file

@ -0,0 +1,141 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SQLInsight.Agent.Updates;
using SQLInsight.Licensing;
using SQLInsight.Repository;
using SQLInsight.Rules.Lifecycle;
using SQLInsight.Updates;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// The agent's update check against a signed feed folder (the offline case), with the status stored in a real
/// repository and shown in the web UI.
/// </summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public sealed class UpdateIntegrationTests(SqlServerFixture sql) : IAsyncLifetime, IDisposable
{
private const string KeyId = "itest-updates";
private readonly string _database = "SQLInsightUpd_" + Guid.NewGuid().ToString("N")[..8];
private readonly string _root = Path.Combine(Path.GetTempPath(), "sqlinsight-upd-" + Guid.NewGuid().ToString("N"));
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private InsightRepository _repository = null!;
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
}
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
public void Dispose()
{
_key.Dispose();
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
private FileRef Publish(string path, byte[] bytes)
{
var full = Path.Combine(_root, "feed", path);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllBytes(full, bytes);
return new FileRef(path, Convert.ToHexString(SHA256.HashData(bytes)), bytes.Length);
}
private static string RepositoryRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
if (File.Exists(Path.Combine(dir.FullName, "SQLInsight.slnx")))
{
return dir.FullName;
}
}
throw new InvalidOperationException("Repository root not found.");
}
[Fact]
public async Task Rule_pack_is_hot_loaded_release_staged_and_status_shown_in_the_web_ui()
{
var lifecycle = Path.Combine(RepositoryRoot(), "src", "SQLInsight.Rules", "Lifecycle");
var pack = RulePack.Create(new Version(2099, 1, 1), File.ReadAllBytes(Path.Combine(lifecycle, "build-catalog.json")), File.ReadAllBytes(Path.Combine(lifecycle, "support-lifecycle.json")));
var manifest = new UpdateManifest
{
KeyId = KeyId,
Channel = "stable",
PublishedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
RulePack = new RulePackRef("2099.1.1", Publish("rulepacks/rulepack-2099.1.1.json", pack)),
Release = new AppRelease
{
Version = "99.0.0",
Released = new DateOnly(2099, 1, 1),
Security = true,
Assets = [new ReleaseAsset(Platform.Current, Publish("releases/sqlinsight-99.0.0.zip", [1, 2, 3]))],
},
};
Publish("stable/manifest.sqli", System.Text.Encoding.UTF8.GetBytes(SignedDocument.Sign(manifest, _key)));
var options = new UpdateOptions { Feed = Path.Combine(_root, "feed"), DataDirectory = Path.Combine(_root, "data"), Application = AppUpdateMode.Automatic };
options.TrustedKeys[KeyId] = Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo());
var knowledge = RuleKnowledge.Embedded();
using var http = new HttpClient();
var service = new UpdateService(Options.Create(options), knowledge, _repository, new SingleClient(http), TimeProvider.System, NullLogger<UpdateService>.Instance);
var status = await service.CheckOnceAsync(CancellationToken.None);
Assert.Null(status.LastError);
Assert.Equal(new Version(2099, 1, 1), knowledge.Current.Version);
Assert.Equal("99.0.0", status.AvailableVersion);
Assert.Equal("99.0.0", status.StagedVersion);
Assert.True(File.Exists(Path.Combine(options.Directories.Pending, "package.zip")));
var stored = await _repository.GetUpdateStatusAsync();
Assert.Equal("2099.1.1", stored!.RulePackVersion);
Assert.True(stored.AvailableSecurity);
// After a restart the verified pack on disk is used again.
Assert.Equal(new Version(2099, 1, 1), UpdateService.LoadKnowledge(options, NullLogger.Instance).Current.Version);
await using var web = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
var html = await web.CreateClient().GetStringAsync("/system");
Assert.Contains("SQLInsight Pro 99.0.0 er tilgængelig og indeholder sikkerhedsrettelser", html, StringComparison.Ordinal);
Assert.Contains("2099.1.1", html, StringComparison.Ordinal);
Assert.Contains("hentet og verificeret", html, StringComparison.Ordinal);
}
[Fact]
public async Task Without_a_feed_the_status_still_reports_version_and_rule_pack()
{
var options = new UpdateOptions { DataDirectory = Path.Combine(_root, "data") };
using var http = new HttpClient();
var service = new UpdateService(Options.Create(options), RuleKnowledge.Embedded(), _repository, new SingleClient(http), TimeProvider.System, NullLogger<UpdateService>.Instance);
await service.CheckOnceAsync(CancellationToken.None);
var stored = await _repository.GetUpdateStatusAsync();
Assert.Null(stored!.Feed);
Assert.Equal(RulePack.Embedded().Version.ToString(), stored.RulePackVersion);
Assert.Equal("indbygget", stored.RulePackSource);
}
private sealed class SingleClient(HttpClient client) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => client;
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Updates\SQLInsight.Updates.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,144 @@
using System.IO.Compression;
using System.Security.Cryptography;
using SQLInsight.Licensing;
using SQLInsight.Rules.Lifecycle;
namespace SQLInsight.Updates.Tests;
/// <summary>A feed folder signed with a throwaway key, plus a data folder and an installation to update.</summary>
public sealed class TestFeed : IDisposable
{
public const string KeyId = "test";
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
public TestFeed()
{
Root = Path.Combine(Path.GetTempPath(), "sqlinsight-updates-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(FeedDirectory);
Directories = new UpdateDirectories(Path.Combine(Root, "data"));
Keys = new TrustedKeys([KeyValuePair.Create(KeyId, Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo()))]);
Feed = new UpdateFeed(FeedDirectory, null);
}
public string Root { get; }
public string FeedDirectory => Path.Combine(Root, "feed");
public string AgentFolder => Path.Combine(Root, "install", "Agent");
public UpdateDirectories Directories { get; }
public TrustedKeys Keys { get; }
public UpdateFeed Feed { get; }
public FileRef AddFile(string path, byte[] bytes)
{
var full = Path.Combine(FeedDirectory, path);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllBytes(full, bytes);
return new FileRef(path, Convert.ToHexString(SHA256.HashData(bytes)), bytes.Length);
}
public string Sign(UpdateManifest manifest) => SignedDocument.Sign(manifest, _key);
public UpdateManifest Publish(string channel = "stable", RulePackRef? rulePack = null, AppRelease? release = null, DateTimeOffset? publishedAt = null, DateTimeOffset? expiresAt = null)
{
var manifest = new UpdateManifest
{
KeyId = KeyId,
Channel = channel,
PublishedAt = publishedAt ?? DateTimeOffset.UtcNow,
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30),
RulePack = rulePack,
Release = release,
};
var path = Path.Combine(FeedDirectory, channel, UpdateFeed.ManifestFile);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, Sign(manifest));
return manifest;
}
public RulePackRef RulePack(Version version)
{
var root = RepositoryRoot();
var bytes = Rules.Lifecycle.RulePack.Create(version,
File.ReadAllBytes(Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "build-catalog.json")),
File.ReadAllBytes(Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "support-lifecycle.json")));
return new RulePackRef(version.ToString(), AddFile($"rulepacks/rulepack-{version}.json", bytes));
}
/// <summary>A release whose Agent folder holds the given program file content.</summary>
public AppRelease Release(string version, string program = "v2", string? platform = null)
{
using var buffer = new MemoryStream();
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
Add(zip, "Agent/SQLInsight.Agent.dll", program);
Add(zip, "Agent/appsettings.json", "{ \"from\": \"package\" }");
Add(zip, "Agent/runtimes/new.dll", "new");
Add(zip, "Web/SQLInsight.Web.dll", "web");
}
return new AppRelease
{
Version = version,
Released = new DateOnly(2026, 10, 1),
Assets = [new ReleaseAsset(platform ?? Platform.Current, AddFile($"releases/sqlinsight-{version}.zip", buffer.ToArray()))],
};
}
public void Install(string program = "v1")
{
Directory.CreateDirectory(AgentFolder);
File.WriteAllText(Path.Combine(AgentFolder, "SQLInsight.Agent.dll"), program);
File.WriteAllText(Path.Combine(AgentFolder, "appsettings.json"), "{ \"from\": \"customer\" }");
}
public UpdateChecker Checker(string channel = "stable") => new(Feed, Keys, Directories, channel, TimeProvider.System);
public void Dispose()
{
_key.Dispose();
if (Directory.Exists(Root))
{
Directory.Delete(Root, recursive: true);
}
}
private static void Add(ZipArchive zip, string name, string content)
{
using var writer = new StreamWriter(zip.CreateEntry(name).Open());
writer.Write(content);
}
private static string RepositoryRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
if (File.Exists(Path.Combine(dir.FullName, "SQLInsight.slnx")))
{
return dir.FullName;
}
}
throw new InvalidOperationException("Repository root not found.");
}
}
/// <summary>Services that "crash" when the installed program file says so.</summary>
public sealed class FakeServices(string programFile) : IServiceControl
{
public List<string> Calls { get; } = [];
public Task StopAsync(string service, CancellationToken cancellationToken)
{
Calls.Add("stop " + service);
return Task.CompletedTask;
}
public Task StartAsync(string service, CancellationToken cancellationToken)
{
Calls.Add("start " + service);
return Task.CompletedTask;
}
public Task<bool> IsRunningAsync(string service, CancellationToken cancellationToken) =>
Task.FromResult(File.ReadAllText(programFile) != "broken");
}

View file

@ -0,0 +1,259 @@
using System.Text;
using SQLInsight.Licensing;
using SQLInsight.Rules.Lifecycle;
namespace SQLInsight.Updates.Tests;
public sealed class UpdateTests : IDisposable
{
private readonly TestFeed _feed = new();
public void Dispose() => _feed.Dispose();
private UpdateApplier Applier(FakeServices services) => new(
new UpdateApplierOptions
{
Channel = "stable",
PrivateRoot = Path.Combine(_feed.Root, "private"),
Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "SQLInsight Agent" }],
HealthCheckDuration = TimeSpan.FromMilliseconds(50),
},
_feed.Directories, _feed.Keys, services, TimeProvider.System, _ => { });
private string AgentFile(string name) => File.ReadAllText(Path.Combine(_feed.AgentFolder, name));
[Fact]
public void Manifest_with_valid_signature_is_accepted()
{
var token = _feed.Sign(new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddDays(1) });
Assert.Equal("stable", ManifestVerifier.Verify(token, _feed.Keys, "stable", DateTimeOffset.UtcNow).Channel);
}
[Fact]
public void Tampered_or_foreign_manifests_are_refused()
{
var now = DateTimeOffset.UtcNow;
var manifest = new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = now, ExpiresAt = now.AddDays(1) };
var token = _feed.Sign(manifest);
var parts = token.Split('.');
var forged = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"keyId\":\"test\",\"channel\":\"preview\",\"publishedAt\":\"2026-01-01T00:00:00Z\",\"expiresAt\":\"2099-01-01T00:00:00Z\"}"))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
using var otherKey = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
Assert.Throws<UpdateException>(() => ManifestVerifier.Verify($"{parts[0]}.{forged}.{parts[2]}", _feed.Keys, "preview", now));
Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(SignedDocument.Sign(manifest, otherKey), _feed.Keys, "stable", now));
Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(token, new TrustedKeys([]), "stable", now));
}
[Fact]
public void Wrong_channel_expired_and_older_manifests_are_refused()
{
var now = DateTimeOffset.UtcNow;
var token = _feed.Sign(new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "preview", PublishedAt = now.AddDays(-2), ExpiresAt = now.AddDays(-1) });
var fresh = _feed.Sign(new UpdateManifest { KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = now.AddDays(-2), ExpiresAt = now.AddDays(1) });
Assert.Contains("kanalen", Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(token, _feed.Keys, "stable", now)).Message, StringComparison.Ordinal);
Assert.Contains("udløb", Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(token, _feed.Keys, "preview", now)).Message, StringComparison.Ordinal);
Assert.Contains("ældre", Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(fresh, _feed.Keys, "stable", now, notBefore: now.AddDays(-1))).Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("../secret.zip")]
[InlineData("/etc/passwd")]
[InlineData("releases\\x.zip")]
[InlineData("C:/x.zip")]
[InlineData("releases//x.zip")]
public void Paths_outside_the_feed_are_refused(string path)
{
var now = DateTimeOffset.UtcNow;
var token = _feed.Sign(new UpdateManifest
{
KeyId = TestFeed.KeyId, Channel = "stable", PublishedAt = now, ExpiresAt = now.AddDays(1),
RulePack = new RulePackRef("2027.1.1", new FileRef(path, "00", 1)),
});
Assert.Throws<UpdateException>(() => ManifestVerifier.Verify(token, _feed.Keys, "stable", now));
}
[Theory]
[InlineData("1.2.0", "1.10.0")]
[InlineData("1.3.0-preview.2", "1.3.0")]
[InlineData("1.3.0-preview.2", "1.3.0-preview.10")]
[InlineData("1.3.0-beta", "1.3.0-rc")]
[InlineData("0.9.9", "1.0.0-preview.1")]
public void Versions_are_ordered_like_semver(string lower, string higher)
{
Assert.True(AppVersion.Parse(lower) < AppVersion.Parse(higher));
Assert.True(AppVersion.Parse(higher) > AppVersion.Parse(lower));
}
[Fact]
public void Build_metadata_and_short_versions_compare_equal()
{
Assert.Equal(AppVersion.Parse("1.2"), AppVersion.Parse("1.2.0+4f1c2a9"));
Assert.Equal("1.2.0", AppVersion.Parse("1.2+abc").ToString());
}
[Fact]
public async Task Newer_rule_pack_is_downloaded_stored_and_reloaded()
{
var embedded = RulePack.Embedded();
var newer = new Version(embedded.Version.Major + 1, 1, 1);
_feed.Publish(rulePack: _feed.RulePack(newer));
var checker = _feed.Checker();
var manifest = await checker.FetchAsync(CancellationToken.None);
var pack = await checker.UpdateRulePackAsync(manifest, embedded, CancellationToken.None);
var again = await checker.UpdateRulePackAsync(manifest, pack!, CancellationToken.None);
var reloaded = new RulePackStore(_feed.Directories).Load(_feed.Keys);
Assert.Equal(newer, pack!.Version);
Assert.Null(again);
Assert.Equal(newer, reloaded!.Version);
Assert.Contains("stable", reloaded.Source, StringComparison.Ordinal);
Assert.NotNull(reloaded.Builds.Latest(Core.Instances.SqlServerRelease.SqlServer2022));
}
[Fact]
public async Task Rule_pack_changed_on_disk_is_not_loaded()
{
var newer = new Version(RulePack.Embedded().Version.Major + 1, 1, 1);
_feed.Publish(rulePack: _feed.RulePack(newer));
var checker = _feed.Checker();
await checker.UpdateRulePackAsync(await checker.FetchAsync(CancellationToken.None), RulePack.Embedded(), CancellationToken.None);
var file = Path.Combine(_feed.Directories.RulePack, "pack.json");
File.WriteAllText(file, File.ReadAllText(file).Replace("CU", "XX", StringComparison.Ordinal));
Assert.Throws<UpdateException>(() => new RulePackStore(_feed.Directories).Load(_feed.Keys));
}
[Fact]
public async Task Rule_pack_that_does_not_match_the_manifest_hash_is_refused()
{
var reference = _feed.RulePack(new Version(2099, 1, 1));
File.AppendAllText(Path.Combine(_feed.FeedDirectory, reference.File.Path), " ");
_feed.Publish(rulePack: reference);
var checker = _feed.Checker();
var manifest = await checker.FetchAsync(CancellationToken.None);
await Assert.ThrowsAsync<UpdateException>(() => checker.UpdateRulePackAsync(manifest, RulePack.Embedded(), CancellationToken.None));
Assert.Null(new RulePackStore(_feed.Directories).Load(_feed.Keys));
}
[Fact]
public async Task Older_manifest_is_refused_after_a_newer_one_was_seen()
{
var now = DateTimeOffset.UtcNow;
_feed.Publish(publishedAt: now);
await _feed.Checker().FetchAsync(CancellationToken.None);
_feed.Publish(publishedAt: now.AddHours(-1));
await Assert.ThrowsAsync<UpdateException>(() => _feed.Checker().FetchAsync(CancellationToken.None));
}
[Fact]
public void Plain_http_feed_is_refused()
{
using var http = new HttpClient();
Assert.Throws<UpdateException>(() => new UpdateFeed("http://updates.example.com/", http));
}
[Fact]
public async Task Update_is_staged_applied_and_keeps_the_configuration()
{
_feed.Install();
_feed.Publish(release: _feed.Release("1.1.0"));
var checker = _feed.Checker();
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
Assert.True(await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None));
var result = await Applier(services).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
Assert.Equal(UpdateOutcome.Applied, result.Outcome);
Assert.Equal("v2", AgentFile("SQLInsight.Agent.dll"));
Assert.Equal("new", AgentFile(Path.Combine("runtimes", "new.dll")));
Assert.Contains("customer", AgentFile("appsettings.json"), StringComparison.Ordinal);
Assert.Equal(["stop SQLInsight Agent", "start SQLInsight Agent"], services.Calls);
Assert.False(Directory.Exists(_feed.Directories.Pending));
Assert.Equal(UpdateOutcome.Applied, _feed.Directories.LastResult()!.Outcome);
}
[Fact]
public async Task Update_that_does_not_start_is_rolled_back()
{
_feed.Install();
_feed.Publish(release: _feed.Release("1.1.0", program: "broken"));
var checker = _feed.Checker();
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
var result = await Applier(services).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
Assert.Equal(UpdateOutcome.RolledBack, result.Outcome);
Assert.Equal("v1", AgentFile("SQLInsight.Agent.dll"));
Assert.Contains("customer", AgentFile("appsettings.json"), StringComparison.Ordinal);
Assert.Equal(["stop SQLInsight Agent", "start SQLInsight Agent", "stop SQLInsight Agent", "start SQLInsight Agent"], services.Calls);
Assert.Equal("v1", File.ReadAllText(Path.Combine(_feed.Root, "private", "backup", "Agent", "SQLInsight.Agent.dll")));
}
[Fact]
public async Task Applier_refuses_a_package_changed_after_staging()
{
_feed.Install();
_feed.Publish(release: _feed.Release("1.1.0"));
var checker = _feed.Checker();
await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
File.AppendAllText(Path.Combine(_feed.Directories.Pending, "package.zip"), "x");
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
var result = await Applier(services).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
Assert.Equal(UpdateOutcome.Failed, result.Outcome);
Assert.Empty(services.Calls);
Assert.Equal("v1", AgentFile("SQLInsight.Agent.dll"));
}
[Fact]
public async Task Applier_refuses_a_manifest_it_cannot_verify()
{
_feed.Install();
_feed.Publish(release: _feed.Release("1.1.0"));
var checker = _feed.Checker();
await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
var applier = new UpdateApplier(
new UpdateApplierOptions { Channel = "stable", PrivateRoot = Path.Combine(_feed.Root, "private"), Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "a" }] },
_feed.Directories, new TrustedKeys([]), services, TimeProvider.System, _ => { });
var result = await applier.ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
Assert.Equal(UpdateOutcome.Failed, result.Outcome);
Assert.Empty(services.Calls);
}
[Fact]
public async Task Nothing_is_staged_or_applied_when_not_newer()
{
_feed.Install();
_feed.Publish(release: _feed.Release("1.0.0"));
var checker = _feed.Checker();
Assert.False(await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None));
Assert.Equal(UpdateOutcome.NothingToDo, (await Applier(new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"))).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None)).Outcome);
}
[Fact]
public async Task Release_without_a_package_for_this_platform_is_reported()
{
_feed.Publish(release: _feed.Release("1.1.0", platform: "plan9-mips"));
var checker = _feed.Checker();
var error = await Assert.ThrowsAsync<UpdateException>(async () => await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None));
Assert.Contains(Platform.Current, error.Message, StringComparison.Ordinal);
}
}

View file

@ -1,13 +1,19 @@
// Vendor tool: generate a signing key pair, issue licenses, verify them. Runs on the vendor's machine only.
// Vendor tool: generate a signing key pair, issue licenses, build rule packs and publish update feeds.
// Runs on the vendor's machine only; the private key never leaves it.
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using SQLInsight.Licensing;
using SQLInsight.Rules.Lifecycle;
using SQLInsight.Updates;
return args.FirstOrDefault() switch
{
"keygen" => KeyGen(Option("--out") ?? "."),
"issue" => Issue(),
"verify" => Verify(),
"rulepack" => BuildRulePack(),
"publish" => Publish(),
_ => Usage(),
};
@ -19,6 +25,14 @@ int Usage()
issue --key <privat.pem> --kid <id> --customer <navn> --plan <Professional|Enterprise>
--expires <yyyy-mm-dd> [--instances <antal>] Udsted licens
verify --public <base64> --kid <id> <licens> Verificér licens
rulepack --version <yyyy.m.d[.n]> --out <pack.json>
[--catalog build-catalog.json] [--lifecycle support-lifecycle.json]
Byg regelpakke af katalogfilerne
publish --key <privat.pem> --kid <id> --feed <mappe> --channel <stable|preview> [--days 90]
[--rulepack <pack.json>]
[--release <version> --asset win-x64=<zip> [--asset linux-x64=<zip>] [--notes <url>] [--security]]
Signér kanalens manifest; det, der ikke angives,
overtages fra det eksisterende manifest
""");
return 1;
}
@ -63,6 +77,114 @@ int Verify()
return status.State is LicenseState.Valid ? 0 : 2;
}
int BuildRulePack()
{
var root = FindRepositoryRoot();
var catalog = Option("--catalog") ?? Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "build-catalog.json");
var lifecycle = Option("--lifecycle") ?? Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "support-lifecycle.json");
var pack = RulePack.Create(Version.Parse(Required("--version")), File.ReadAllBytes(catalog), File.ReadAllBytes(lifecycle));
File.WriteAllBytes(Required("--out"), pack);
Console.WriteLine($"Regelpakke {Required("--version")} skrevet til {Required("--out")}");
return 0;
}
int Publish()
{
using var key = ECDsa.Create();
key.ImportFromPem(File.ReadAllText(Required("--key")));
var kid = Required("--kid");
var feed = Required("--feed");
var channel = Required("--channel");
if (channel is not (UpdateManifest.StableChannel or UpdateManifest.PreviewChannel))
{
Console.Error.WriteLine("--channel skal være stable eller preview.");
return 1;
}
var manifestPath = Path.Combine(feed, channel, UpdateFeed.ManifestFile);
var keys = new TrustedKeys([KeyValuePair.Create(kid, Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()))]);
var previous = File.Exists(manifestPath) ? SignedDocument.Verify<UpdateManifest>(File.ReadAllText(manifestPath), keys, m => m.KeyId) : null;
var rulePack = previous?.RulePack;
if (Option("--rulepack") is { } packPath)
{
var bytes = File.ReadAllBytes(packPath);
var pack = RulePack.Parse(bytes, packPath);
rulePack = new RulePackRef(pack.Version.ToString(), AddFile(feed, "rulepacks", $"rulepack-{pack.Version}.json", bytes));
}
var release = previous?.Release;
if (Option("--release") is { } version)
{
AppVersion.Parse(version);
var assets = new List<ReleaseAsset>();
for (var i = 0; i < args.Length - 1; i++)
{
if (args[i] == "--asset" && args[i + 1].Split('=', 2) is [var platform, var file])
{
assets.Add(new ReleaseAsset(platform, AddFile(feed, "releases", $"sqlinsight-{version}-{platform}.zip", File.ReadAllBytes(file))));
}
}
if (assets.Count == 0)
{
Console.Error.WriteLine("--release kræver mindst én --asset <platform>=<zip>.");
return 1;
}
release = new AppRelease
{
Version = version,
Released = DateOnly.FromDateTime(DateTime.UtcNow),
Security = args.Contains("--security"),
NotesUrl = Option("--notes"),
Assets = assets,
};
}
var now = DateTimeOffset.UtcNow;
var manifest = new UpdateManifest
{
KeyId = kid,
Channel = channel,
PublishedAt = now,
ExpiresAt = now.AddDays(int.Parse(Option("--days") ?? "90", CultureInfo.InvariantCulture)),
RulePack = rulePack,
Release = release,
};
Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
File.WriteAllText(manifestPath, SignedDocument.Sign(manifest, key));
Console.WriteLine($"{manifestPath}: regelpakke {rulePack?.Version ?? "-"}, version {release?.Version ?? "-"}, udløber {manifest.ExpiresAt:yyyy-MM-dd}");
return 0;
}
static FileRef AddFile(string feed, string folder, string name, byte[] bytes)
{
var relative = $"{folder}/{name}";
var path = Path.Combine(feed, folder, name);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (File.Exists(path) && !File.ReadAllBytes(path).AsSpan().SequenceEqual(bytes))
{
throw new InvalidOperationException($"{relative} findes allerede med andet indhold. Udgivne filer ændres aldrig; brug et nyt versionsnummer.");
}
File.WriteAllBytes(path, bytes);
return new FileRef(relative, Convert.ToHexString(SHA256.HashData(bytes)), bytes.LongLength);
}
static string FindRepositoryRoot()
{
for (var dir = new DirectoryInfo(Environment.CurrentDirectory); dir is not null; dir = dir.Parent)
{
if (File.Exists(Path.Combine(dir.FullName, "SQLInsight.slnx")))
{
return dir.FullName;
}
}
throw new InvalidOperationException("Kør fra repositoriet, eller angiv --catalog og --lifecycle.");
}
string? Option(string name)
{
var i = Array.IndexOf(args, name);

View file

@ -5,5 +5,6 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Updates\SQLInsight.Updates.csproj" />
</ItemGroup>
</Project>