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
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:
parent
7088efff52
commit
3121b598f9
40 changed files with 2739 additions and 75 deletions
|
|
@ -13,6 +13,8 @@
|
||||||
<Company>SEOSoft</Company>
|
<Company>SEOSoft</Company>
|
||||||
<Product>SQLInsight Pro</Product>
|
<Product>SQLInsight Pro</Product>
|
||||||
<Copyright>Copyright (c) SEOSoft</Copyright>
|
<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>
|
||||||
|
|
||||||
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
|
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" 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="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.Extensions.Hosting.Systemd" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" />
|
<Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" />
|
||||||
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
|
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
|
||||||
<Project Path="src/SQLInsight.Licensing/SQLInsight.Licensing.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.Notifications/SQLInsight.Notifications.csproj" />
|
||||||
<Project Path="src/SQLInsight.Repository/SQLInsight.Repository.csproj" />
|
<Project Path="src/SQLInsight.Repository/SQLInsight.Repository.csproj" />
|
||||||
<Project Path="src/SQLInsight.Rules/SQLInsight.Rules.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.Licensing.Tests/SQLInsight.Licensing.Tests.csproj" />
|
||||||
<Project Path="tests/SQLInsight.Notifications.Tests/SQLInsight.Notifications.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.Rules.Tests/SQLInsight.Rules.Tests.csproj" />
|
||||||
|
<Project Path="tests/SQLInsight.Updates.Tests/SQLInsight.Updates.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tools/">
|
<Folder Name="/tools/">
|
||||||
<Project Path="tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj" />
|
<Project Path="tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -129,4 +129,4 @@ Begge programmer understøtter systemd (`Type=notify`). Windows-login på Linux
|
||||||
|
|
||||||
## Opdatering
|
## 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
137
docs/opdatering.md
Normal 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.
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SQLInsight.Agent;
|
using SQLInsight.Agent;
|
||||||
using SQLInsight.Agent.Monitoring;
|
using SQLInsight.Agent.Monitoring;
|
||||||
|
using SQLInsight.Agent.Updates;
|
||||||
using SQLInsight.Connectivity;
|
using SQLInsight.Connectivity;
|
||||||
using SQLInsight.Notifications;
|
using SQLInsight.Notifications;
|
||||||
using SQLInsight.Repository;
|
using SQLInsight.Repository;
|
||||||
using SQLInsight.Rules;
|
using SQLInsight.Rules;
|
||||||
using SQLInsight.Rules.Engine;
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
if (UpdateCommands.Handles(args))
|
||||||
|
{
|
||||||
|
return await UpdateCommands.RunAsync(args);
|
||||||
|
}
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Agent");
|
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.Configure<AgentOptions>(builder.Configuration.GetSection(AgentOptions.SectionName));
|
||||||
builder.Services.AddSingleton(TimeProvider.System);
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
|
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 =>
|
builder.Services.AddSingleton(sp =>
|
||||||
{
|
{
|
||||||
var connectionString = sp.GetRequiredService<IOptions<AgentOptions>>().Value.Repository.ConnectionString;
|
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.AddHttpClient("hub", c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||||
builder.Services.AddSingleton<SQLInsight.Agent.Hub.HubOutbox>();
|
builder.Services.AddSingleton<SQLInsight.Agent.Hub.HubOutbox>();
|
||||||
builder.Services.AddHostedService<SQLInsight.Agent.Hub.HubSyncService>();
|
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>();
|
builder.Services.AddHostedService<MonitoringService>();
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
host.Run();
|
await host.RunAsync();
|
||||||
|
return 0;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
<ProjectReference Include="..\SQLInsight.Notifications\SQLInsight.Notifications.csproj" />
|
<ProjectReference Include="..\SQLInsight.Notifications\SQLInsight.Notifications.csproj" />
|
||||||
<ProjectReference Include="..\SQLInsight.Contracts\SQLInsight.Contracts.csproj" />
|
<ProjectReference Include="..\SQLInsight.Contracts\SQLInsight.Contracts.csproj" />
|
||||||
<ProjectReference Include="..\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
|
<ProjectReference Include="..\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
|
||||||
|
<ProjectReference Include="..\SQLInsight.Updates\SQLInsight.Updates.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
|
|
||||||
226
src/SQLInsight.Agent/Updates/UpdateCommands.cs
Normal file
226
src/SQLInsight.Agent/Updates/UpdateCommands.cs
Normal 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 <url|mappe>]</c>: download and install the newest version now (administrator).</item>
|
||||||
|
/// <item><c>apply-update</c>: for the scheduled update task; installs a staged version when the mode is Automatic and
|
||||||
|
/// the maintenance window is open.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Installing runs from a copy of the program in the updater's folder, so no file in the installation is in use.
|
||||||
|
/// </summary>
|
||||||
|
public static class UpdateCommands
|
||||||
|
{
|
||||||
|
private const string RunnerFlag = "--runner";
|
||||||
|
|
||||||
|
public static bool Handles(string[] args) => args.FirstOrDefault() is "update" or "apply-update";
|
||||||
|
|
||||||
|
public static async Task<int> RunAsync(string[] args)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(args);
|
||||||
|
var agentFolder = Option(args, "--install-dir") ?? AppContext.BaseDirectory;
|
||||||
|
var settings = LoadOptions(agentFolder);
|
||||||
|
if (Option(args, "--feed") is { } feed)
|
||||||
|
{
|
||||||
|
settings.Feed = feed;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (args.ElementAtOrDefault(0), args.ElementAtOrDefault(1)) switch
|
||||||
|
{
|
||||||
|
("update", "check") => await CheckAsync(settings, http).ConfigureAwait(false),
|
||||||
|
("update", "apply") => await ApplyNowAsync(args, settings, agentFolder, http).ConfigureAwait(false),
|
||||||
|
("apply-update", _) when args.Contains(RunnerFlag) => await RunApplierAsync(args, settings, agentFolder).ConfigureAwait(false),
|
||||||
|
("apply-update", _) => ScheduledApply(args, settings, agentFolder),
|
||||||
|
_ => Usage(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is UpdateException or IOException or UnauthorizedAccessException or FormatException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(ex.Message);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> CheckAsync(UpdateOptions settings, HttpClient http)
|
||||||
|
{
|
||||||
|
var checker = Checker(settings, http);
|
||||||
|
var manifest = (await checker.FetchAsync(CancellationToken.None).ConfigureAwait(false)).Manifest;
|
||||||
|
var installed = AppVersion.Running;
|
||||||
|
var pack = RulePack.Embedded();
|
||||||
|
Console.WriteLine($"Feed: {settings.Feed} ({settings.Channel}, udgivet {manifest.PublishedAt:yyyy-MM-dd HH:mm} UTC)");
|
||||||
|
Console.WriteLine($"Installeret: {installed}");
|
||||||
|
Console.WriteLine($"Tilgængelig: {manifest.Release?.Version ?? "-"}{(UpdateChecker.NewerRelease(manifest, installed) is { Security: true } ? " (sikkerhedsopdatering)" : string.Empty)}");
|
||||||
|
Console.WriteLine($"Regelpakke: {manifest.RulePack?.Version ?? "-"} (indbygget {pack.Version})");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Administrator: download and install now, regardless of mode and maintenance window.</summary>
|
||||||
|
private static async Task<int> ApplyNowAsync(string[] args, UpdateOptions settings, string agentFolder, HttpClient http)
|
||||||
|
{
|
||||||
|
var checker = Checker(settings, http);
|
||||||
|
var manifest = await checker.FetchAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (!await checker.StageAsync(manifest, AppVersion.Running, CancellationToken.None).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Version {AppVersion.Running} er den nyeste på {settings.Channel}-kanalen.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"Version {manifest.Manifest.Release!.Version} er hentet og verificeret.");
|
||||||
|
return LaunchRunner(args, settings, agentFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Scheduled task: only in Automatic mode, only in the maintenance window, only if something is staged.</summary>
|
||||||
|
private static int ScheduledApply(string[] args, UpdateOptions settings, string agentFolder)
|
||||||
|
{
|
||||||
|
if (settings.Application != AppUpdateMode.Automatic)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Automatisk installation er slået fra (Application = {settings.Application}).");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.InMaintenanceWindow(TimeOnly.FromDateTime(DateTime.Now)))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Uden for vedligeholdelsesvinduet {settings.MaintenanceWindow}.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(Path.Combine(settings.Directories.Pending, UpdateFeed.ManifestFile)))
|
||||||
|
{
|
||||||
|
Console.WriteLine("Ingen opdatering venter.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LaunchRunner(args, settings, agentFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies the program to the updater's folder and runs <c>apply-update --runner</c> from there. On Windows the
|
||||||
|
/// installation's files are locked while this process runs, so it does not wait; on Linux it waits, so a systemd
|
||||||
|
/// oneshot unit does not kill the updater when it exits.
|
||||||
|
/// </summary>
|
||||||
|
private static int LaunchRunner(string[] args, UpdateOptions settings, string agentFolder)
|
||||||
|
{
|
||||||
|
var runner = Path.Combine(settings.UpdaterRoot(agentFolder), "runner");
|
||||||
|
if (Directory.Exists(runner))
|
||||||
|
{
|
||||||
|
Directory.Delete(runner, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(agentFolder, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var target = Path.Combine(runner, Path.GetRelativePath(agentFolder, file));
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||||
|
File.Copy(file, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = Environment.ProcessPath ?? throw new InvalidOperationException("Programmets sti er ukendt.");
|
||||||
|
var viaDotnet = Path.GetFileNameWithoutExtension(host).Equals("dotnet", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var start = new ProcessStartInfo(viaDotnet ? host : Path.Combine(runner, Path.GetFileName(host))) { UseShellExecute = false };
|
||||||
|
if (viaDotnet)
|
||||||
|
{
|
||||||
|
start.ArgumentList.Add(Path.Combine(runner, "SQLInsight.Agent.dll"));
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] runnerArgs = ["apply-update", RunnerFlag, "--install-dir", agentFolder, "--wait-pid", Environment.ProcessId.ToString(CultureInfo.InvariantCulture)];
|
||||||
|
foreach (var arg in runnerArgs.Concat(Option(args, "--feed") is { } feed ? ["--feed", feed] : []))
|
||||||
|
{
|
||||||
|
start.ArgumentList.Add(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var process = Process.Start(start) ?? throw new UpdateException("Opdateringen kunne ikke startes.");
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Opdateringen kører nu i baggrunden. Resultatet skrives i {settings.Directories.ResultFile} og vises i webbrugerfladen.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.WaitForExit();
|
||||||
|
return process.ExitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> RunApplierAsync(string[] args, UpdateOptions settings, string agentFolder)
|
||||||
|
{
|
||||||
|
// On Windows the process that started us holds the installation's files open until it exits.
|
||||||
|
if (OperatingSystem.IsWindows() && int.TryParse(Option(args, "--wait-pid"), NumberStyles.None, CultureInfo.InvariantCulture, out var parent))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var launcher = Process.GetProcessById(parent);
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
|
||||||
|
await launcher.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Already gone, or still running after a minute: go ahead, a locked file triggers a rollback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = settings.UpdaterRoot(agentFolder);
|
||||||
|
var logFile = Path.Combine(root, "update.log");
|
||||||
|
void Log(string line)
|
||||||
|
{
|
||||||
|
var stamped = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss} {line}";
|
||||||
|
Console.WriteLine(stamped);
|
||||||
|
File.AppendAllText(logFile, stamped + Environment.NewLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
var applier = new UpdateApplier(
|
||||||
|
new UpdateApplierOptions
|
||||||
|
{
|
||||||
|
Channel = settings.Channel,
|
||||||
|
PrivateRoot = Path.Combine(root, "work"),
|
||||||
|
Components = settings.ComponentsFor(agentFolder),
|
||||||
|
HealthCheckDuration = settings.HealthCheckDuration,
|
||||||
|
},
|
||||||
|
settings.Directories, settings.Keys, ServiceControl.ForCurrentOs(), TimeProvider.System, Log);
|
||||||
|
var result = await applier.ApplyAsync(AppVersion.Running, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
return result.Outcome is UpdateOutcome.Applied or UpdateOutcome.NothingToDo ? 0 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UpdateChecker Checker(UpdateOptions settings, HttpClient http)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.Feed))
|
||||||
|
{
|
||||||
|
throw new UpdateException("Der er intet opdateringsfeed. Angiv SQLInsight:Updates:Feed eller --feed <url|mappe>.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UpdateChecker(new UpdateFeed(settings.Feed, http), settings.Keys, settings.Directories, settings.Channel, TimeProvider.System);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UpdateOptions LoadOptions(string agentFolder)
|
||||||
|
{
|
||||||
|
var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production";
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(agentFolder)
|
||||||
|
.AddJsonFile("appsettings.json", optional: true)
|
||||||
|
.AddJsonFile($"appsettings.{environment}.json", optional: true)
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
return configuration.GetSection(UpdateOptions.SectionName).Get<UpdateOptions>() ?? new UpdateOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Option(string[] args, string name)
|
||||||
|
{
|
||||||
|
var i = Array.IndexOf(args, name);
|
||||||
|
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Usage()
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("""
|
||||||
|
SQLInsight.Agent update check Vis hvad opdateringsfeedet tilbyder
|
||||||
|
SQLInsight.Agent update apply [--feed <url|mappe>] Hent og installér nyeste version nu (administrator)
|
||||||
|
SQLInsight.Agent apply-update Til den planlagte opdateringsopgave
|
||||||
|
""");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/SQLInsight.Agent/Updates/UpdateOptions.cs
Normal file
89
src/SQLInsight.Agent/Updates/UpdateOptions.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/SQLInsight.Agent/Updates/UpdateService.cs
Normal file
141
src/SQLInsight.Agent/Updates/UpdateService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -15,4 +15,10 @@ public sealed class HubServerOptions
|
||||||
|
|
||||||
/// <summary>Portal login attempts allowed per client IP per minute.</summary>
|
/// <summary>Portal login attempts allowed per client IP per minute.</summary>
|
||||||
public int LoginAttemptsPerMinute { get; set; } = 10;
|
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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,20 @@ public static class HubProgram
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
app.MapStaticAssets();
|
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("/", () => Results.Redirect("/portal"));
|
||||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||||
FindingsEndpoint.Map(app);
|
FindingsEndpoint.Map(app);
|
||||||
|
|
|
||||||
52
src/SQLInsight.Repository/InsightRepository.Updates.cs
Normal file
52
src/SQLInsight.Repository/InsightRepository.Updates.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/SQLInsight.Repository/Migrations/0003_update_status.sql
Normal file
22
src/SQLInsight.Repository/Migrations/0003_update_status.sql
Normal 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
|
||||||
|
);
|
||||||
|
|
@ -11,11 +11,15 @@ namespace SQLInsight.Rules;
|
||||||
|
|
||||||
public static class BuiltInRules
|
public static class BuiltInRules
|
||||||
{
|
{
|
||||||
public static IReadOnlyList<IRule> Create() =>
|
/// <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
|
// Version
|
||||||
new SupportLifecycleRule(SupportLifecycleCatalog.LoadEmbedded()),
|
new SupportLifecycleRule(knowledge),
|
||||||
new PatchLevelRule(BuildCatalog.LoadEmbedded(), SupportLifecycleCatalog.LoadEmbedded()),
|
new PatchLevelRule(knowledge),
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
new MaxServerMemoryDefaultRule(),
|
new MaxServerMemoryDefaultRule(),
|
||||||
|
|
@ -76,3 +80,4 @@ public static class BuiltInRules
|
||||||
new DeadlockRule(),
|
new DeadlockRule(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,29 @@ public sealed class BuildCatalog(DateOnly asOf, IEnumerable<KnownBuild> builds)
|
||||||
{
|
{
|
||||||
using var stream = typeof(BuildCatalog).Assembly.GetManifestResourceStream(ResourceName)
|
using var stream = typeof(BuildCatalog).Assembly.GetManifestResourceStream(ResourceName)
|
||||||
?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing.");
|
?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing.");
|
||||||
var document = JsonSerializer.Deserialize<Document>(stream, JsonOptions)
|
return From(JsonSerializer.Deserialize<Document>(stream, JsonOptions));
|
||||||
?? throw new InvalidOperationException("The build catalog is empty.");
|
}
|
||||||
return new BuildCatalog(document.AsOf, document.Releases.Select(r =>
|
|
||||||
|
/// <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)));
|
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()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,13 @@ namespace SQLInsight.Rules.Lifecycle;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Instance behind the newest known cumulative update, security update or service pack.
|
/// Instance behind the newest known cumulative update, security update or service pack.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>After this long the catalog itself is flagged as possibly outdated.</summary>
|
||||||
public static readonly TimeSpan CatalogMaxAge = TimeSpan.FromDays(120);
|
public static readonly TimeSpan CatalogMaxAge = TimeSpan.FromDays(120);
|
||||||
|
|
||||||
|
|
@ -21,6 +26,8 @@ public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
var instance = context.Instance;
|
var instance = context.Instance;
|
||||||
|
var pack = knowledge.Current;
|
||||||
|
var catalog = pack.Builds;
|
||||||
var latest = catalog.Latest(instance.Release)
|
var latest = catalog.Latest(instance.Release)
|
||||||
?? throw new Core.Collection.DataUnavailableException($"Regelpakken kender ikke seneste build for {instance.Release.DisplayName()}. Opdater SQLInsight Pro.");
|
?? 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 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 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);
|
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("Installeret build", current),
|
||||||
new("Seneste kendte build", $"{latest.Build} ({latest.Name}, {latest.Kb}, {latest.Released.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)})"),
|
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)
|
new("Katalog pr.", catalog.AsOf.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + $" (regelpakke {pack.Version})"
|
||||||
+ (catalogAge > CatalogMaxAge ? " (forældet: opdater SQLInsight Pro for at se nyere opdateringer)" : string.Empty)),
|
+ (catalogAge > CatalogMaxAge ? " (forældet: opdater regelpakken eller SQLInsight Pro for at se nyere opdateringer)" : string.Empty)),
|
||||||
};
|
};
|
||||||
|
|
||||||
return RuleEvaluation.Found(CreateFinding(
|
return RuleEvaluation.Found(CreateFinding(
|
||||||
|
|
|
||||||
108
src/SQLInsight.Rules/Lifecycle/RulePack.cs
Normal file
108
src/SQLInsight.Rules/Lifecycle/RulePack.cs
Normal 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));
|
||||||
|
}
|
||||||
|
|
@ -31,9 +31,27 @@ public sealed class SupportLifecycleCatalog
|
||||||
{
|
{
|
||||||
using var stream = typeof(SupportLifecycleCatalog).Assembly.GetManifestResourceStream(ResourceName)
|
using var stream = typeof(SupportLifecycleCatalog).Assembly.GetManifestResourceStream(ResourceName)
|
||||||
?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing.");
|
?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing.");
|
||||||
var document = JsonSerializer.Deserialize<CatalogDocument>(stream, JsonOptions)
|
return From(JsonSerializer.Deserialize<CatalogDocument>(stream, JsonOptions));
|
||||||
?? throw new InvalidOperationException("The support lifecycle catalog is empty.");
|
}
|
||||||
return new SupportLifecycleCatalog(document.Releases);
|
|
||||||
|
/// <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);
|
public SupportLifecycle? Find(SqlServerRelease release) => _entries.GetValueOrDefault(release);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// 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.
|
/// Needs no capabilities, so it runs on every version from SQL Server 2000.
|
||||||
/// </summary>
|
/// </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";
|
public const string RuleId = "SQLI-VER-001";
|
||||||
|
|
||||||
/// <summary>How long before end of extended support the finding escalates to High.</summary>
|
/// <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.");
|
$"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)
|
if (lifecycle?.MainstreamEnd is not { } mainstreamEnd || lifecycle.ExtendedEnd is not { } extendedEnd)
|
||||||
{
|
{
|
||||||
return RuleEvaluation.NotMeasurable(
|
return RuleEvaluation.NotMeasurable(
|
||||||
|
|
|
||||||
90
src/SQLInsight.Updates/AppVersion.cs
Normal file
90
src/SQLInsight.Updates/AppVersion.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/SQLInsight.Updates/ManifestVerifier.cs
Normal file
77
src/SQLInsight.Updates/ManifestVerifier.cs
Normal 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}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/SQLInsight.Updates/RulePackStore.cs
Normal file
45
src/SQLInsight.Updates/RulePackStore.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/SQLInsight.Updates/SQLInsight.Updates.csproj
Normal file
12
src/SQLInsight.Updates/SQLInsight.Updates.csproj
Normal 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>
|
||||||
83
src/SQLInsight.Updates/ServiceControl.cs
Normal file
83
src/SQLInsight.Updates/ServiceControl.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
269
src/SQLInsight.Updates/UpdateApplier.cs
Normal file
269
src/SQLInsight.Updates/UpdateApplier.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/SQLInsight.Updates/UpdateChecker.cs
Normal file
92
src/SQLInsight.Updates/UpdateChecker.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/SQLInsight.Updates/UpdateDirectories.cs
Normal file
79
src/SQLInsight.Updates/UpdateDirectories.cs
Normal 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);
|
||||||
151
src/SQLInsight.Updates/UpdateFeed.cs
Normal file
151
src/SQLInsight.Updates/UpdateFeed.cs
Normal 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><channel>/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/SQLInsight.Updates/UpdateManifest.cs
Normal file
45
src/SQLInsight.Updates/UpdateManifest.cs
Normal 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);
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
<span class="nav-label">Overvågning</span>
|
<span class="nav-label">Overvågning</span>
|
||||||
<NavLink href="/" Match="NavLinkMatch.All"><Icon Name="servers" /> Instanser</NavLink>
|
<NavLink href="/" Match="NavLinkMatch.All"><Icon Name="servers" /> Instanser</NavLink>
|
||||||
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
|
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
|
||||||
|
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
|
||||||
|
|
||||||
<span class="nav-label">Community</span>
|
<span class="nav-label">Community</span>
|
||||||
<a href="https://git.seosoft.dk/seo/sqlinsight-pro" rel="noopener"><Icon Name="users" /> Projekt og forum</a>
|
<a href="https://git.seosoft.dk/seo/sqlinsight-pro" rel="noopener"><Icon Name="users" /> Projekt og forum</a>
|
||||||
|
|
|
||||||
150
src/SQLInsight.Web/Components/Pages/SystemStatus.razor
Normal file
150
src/SQLInsight.Web/Components/Pages/SystemStatus.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -154,3 +154,6 @@ meter { width: 64px; height: 8px; margin-right: .4rem; vertical-align: middle; }
|
||||||
.page, .topbar { padding-left: 1rem; padding-right: 1rem; }
|
.page, .topbar { padding-left: 1rem; padding-right: 1rem; }
|
||||||
.grid2 { grid-template-columns: 1fr; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -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.BadRequest, (await PostAsync(license, new { schemaVersion = 99 })).StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await PostAsync(license, badSeverity)).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
141
tests/SQLInsight.IntegrationTests/UpdateIntegrationTests.cs
Normal file
141
tests/SQLInsight.IntegrationTests/UpdateIntegrationTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
144
tests/SQLInsight.Updates.Tests/TestFeed.cs
Normal file
144
tests/SQLInsight.Updates.Tests/TestFeed.cs
Normal 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");
|
||||||
|
}
|
||||||
259
tests/SQLInsight.Updates.Tests/UpdateTests.cs
Normal file
259
tests/SQLInsight.Updates.Tests/UpdateTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using SQLInsight.Licensing;
|
using SQLInsight.Licensing;
|
||||||
|
using SQLInsight.Rules.Lifecycle;
|
||||||
|
using SQLInsight.Updates;
|
||||||
|
|
||||||
return args.FirstOrDefault() switch
|
return args.FirstOrDefault() switch
|
||||||
{
|
{
|
||||||
"keygen" => KeyGen(Option("--out") ?? "."),
|
"keygen" => KeyGen(Option("--out") ?? "."),
|
||||||
"issue" => Issue(),
|
"issue" => Issue(),
|
||||||
"verify" => Verify(),
|
"verify" => Verify(),
|
||||||
|
"rulepack" => BuildRulePack(),
|
||||||
|
"publish" => Publish(),
|
||||||
_ => Usage(),
|
_ => Usage(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -19,6 +25,14 @@ int Usage()
|
||||||
issue --key <privat.pem> --kid <id> --customer <navn> --plan <Professional|Enterprise>
|
issue --key <privat.pem> --kid <id> --customer <navn> --plan <Professional|Enterprise>
|
||||||
--expires <yyyy-mm-dd> [--instances <antal>] Udsted licens
|
--expires <yyyy-mm-dd> [--instances <antal>] Udsted licens
|
||||||
verify --public <base64> --kid <id> <licens> Verificér 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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +77,114 @@ int Verify()
|
||||||
return status.State is LicenseState.Valid ? 0 : 2;
|
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)
|
string? Option(string name)
|
||||||
{
|
{
|
||||||
var i = Array.IndexOf(args, name);
|
var i = Array.IndexOf(args, name);
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,6 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
|
<ProjectReference Include="..\..\src\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\SQLInsight.Updates\SQLInsight.Updates.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue