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
144 lines
5.5 KiB
C#
144 lines
5.5 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()))],
|
|
};
|
|
}
|
|
|
|
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");
|
|
}
|