Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run
CI / MSI-installer (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
installer/SQLInsight.wxs (WiX 3 syntax) builds on Linux with wixl (installer/build-msi.sh) and in Forgejo CI on every v* tag. Agent and Web are installed self-contained as Windows services under virtual service accounts (or a gMSA), with a settings page in the wizard or MSI properties for silent installs; the values go to sqlinsight.ini, read after appsettings.json. The data folder is locked to SYSTEM, Administrators and the agent account, the event log source is registered, the web port is opened in the firewall, and the automatic update task is an optional feature. Upgrades happen in place, keep edited settings and the chosen features; uninstall keeps the settings. msi-tables.py writes what wixl lacks (component and feature conditions, Permanent/NeverOverwrite, vital services, REG_EXPAND_SZ) and verify-msi.py checks the result (sequencing, deferred actions, ASCII only). test-msi-wine.sh runs install, upgrade and uninstall under Wine. Installations made with the MSI get new versions as MSI packages run by the update task, so Windows Installer stays in charge of the files. The example instance is removed from the shipped appsettings.json, and the CLI writes UTF-8 to the console. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
167 lines
6.4 KiB
C#
167 lines
6.4 KiB
C#
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()))],
|
|
};
|
|
}
|
|
|
|
/// <summary>A release published as an MSI for installations made with the MSI.</summary>
|
|
public AppRelease MsiRelease(string version) => new()
|
|
{
|
|
Version = version,
|
|
Released = new DateOnly(2026, 10, 1),
|
|
Assets =
|
|
[
|
|
new ReleaseAsset("win-x64", AddFile($"releases/sqlinsight-{version}-win-x64.zip", [1, 2, 3])),
|
|
new ReleaseAsset("win-x64-msi", AddFile($"releases/SQLInsight-Pro-{version}-x64.msi", [0xD0, 0xCF, 0x11, 0xE0, 4, 5, 6])),
|
|
],
|
|
};
|
|
|
|
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", string? platform = null) => new(Feed, Keys, Directories, channel, TimeProvider.System, platform ?? Platform.Current);
|
|
|
|
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");
|
|
}
|
|
|
|
public sealed class FakeMsi(int exitCode) : IMsiInstaller
|
|
{
|
|
public List<(string Msi, byte[] Content)> Installed { get; } = [];
|
|
|
|
public Task<int> InstallAsync(string msiPath, string logPath, CancellationToken cancellationToken)
|
|
{
|
|
Installed.Add((msiPath, File.ReadAllBytes(msiPath)));
|
|
return Task.FromResult(exitCode);
|
|
}
|
|
}
|