test: prove package lifecycle isolation and recovery

This commit is contained in:
codex
2026-08-06 08:24:00 +03:00
parent c4c611b35b
commit 517105bd3f
4 changed files with 69 additions and 3 deletions
@@ -2,8 +2,11 @@ using Launcher.Next.Core;
namespace Launcher.Next.Application; namespace Launcher.Next.Application;
public sealed class PackageResolutionUseCase(IPackageCatalogPackageAcquirer catalog, IPackageSynchronizer synchronizer, IPackageRequirementCacheBinder binder, ILastKnownGoodPackageResolver cache, ILauncherTelemetry telemetry) public sealed class PackageResolutionUseCase
{ {
private readonly IPackageCatalogPackageAcquirer catalog; private readonly IPackageSynchronizer synchronizer; private readonly IPackageRequirementCacheBinder binder; private readonly ILastKnownGoodPackageResolver cache; private readonly ILauncherTelemetry telemetry; private readonly IPackageMaterializer? materializer;
public PackageResolutionUseCase(IPackageCatalogPackageAcquirer c, IPackageSynchronizer s, IPackageRequirementCacheBinder b, ILastKnownGoodPackageResolver l, ILauncherTelemetry t) { catalog=c; synchronizer=s; binder=b; cache=l; telemetry=t; }
public PackageResolutionUseCase(IPackageCatalogPackageAcquirer c, IPackageSynchronizer s, IPackageRequirementCacheBinder b, ILastKnownGoodPackageResolver l, IPackageMaterializer m, ILauncherTelemetry t) : this(c,s,b,l,t) { materializer=m; }
public async Task<PackageResolutionResult> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken) public async Task<PackageResolutionResult> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken)
{ {
var evidence = new List<PackageResolutionEvidence> { new("PACKAGE_REQUIREMENT_RECEIVED", requirement.PackageId) }; var evidence = new List<PackageResolutionEvidence> { new("PACKAGE_REQUIREMENT_RECEIVED", requirement.PackageId) };
@@ -13,6 +16,13 @@ public sealed class PackageResolutionUseCase(IPackageCatalogPackageAcquirer cata
var online = await catalog.AcquireAsync(requirement, cancellationToken); var online = await catalog.AcquireAsync(requirement, cancellationToken);
if (online is not null && Matches(requirement, online.Manifest)) if (online is not null && Matches(requirement, online.Manifest))
{ {
if (materializer is not null)
{
var committed = await materializer.MaterializeAsync(online.Manifest, online.SourceRoot, cancellationToken);
await binder.BindAsync(requirement, committed.Version, cancellationToken);
evidence.Add(new("ONLINE_PACKAGE_MATERIALIZED_NOT_ACTIVATED", committed.Version));
return new(new CachedPackageResolution(committed.PackageId, committed.Version, committed.LocalPackageRoot, false, "MATERIALIZED_NOT_ACTIVATED"), true, evidence);
}
await synchronizer.SynchronizeAsync(online.Manifest, online.SourceRoot, cancellationToken); await synchronizer.SynchronizeAsync(online.Manifest, online.SourceRoot, cancellationToken);
await binder.BindAsync(requirement, online.Manifest.Version, cancellationToken); await binder.BindAsync(requirement, online.Manifest.Version, cancellationToken);
var selected = await cache.ResolveAsync(requirement, cancellationToken); var selected = await cache.ResolveAsync(requirement, cancellationToken);
+2
View File
@@ -20,6 +20,8 @@ public sealed record ActivationResult(string CurrentVersion, string? PreviousVer
public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc); public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc);
public sealed record PackageActivationResult(string Outcome, PackageActivationReceipt? Receipt); public sealed record PackageActivationResult(string Outcome, PackageActivationReceipt? Receipt);
public sealed record PackageRollbackResult(string Outcome, string? CurrentVersion); public sealed record PackageRollbackResult(string Outcome, string? CurrentVersion);
public sealed record ImmutableLocalPackage(string PackageId, string Version, string LocalPackageRoot);
public interface IPackageMaterializer { Task<ImmutableLocalPackage> MaterializeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); }
public sealed record PackageRequirement(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity, bool AllowOfflineFallback); public sealed record PackageRequirement(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity, bool AllowOfflineFallback);
public sealed record CatalogPackage(PackageManifest Manifest, string SourceRoot); public sealed record CatalogPackage(PackageManifest Manifest, string SourceRoot);
public sealed record PackageResolutionEvidence(string Code, string Detail); public sealed record PackageResolutionEvidence(string Code, string Detail);
@@ -5,8 +5,7 @@ namespace Launcher.Next.Infrastructure;
public sealed class Sha256FileHasher : IFileHasher public sealed class Sha256FileHasher : IFileHasher
{ public async Task<string> Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } } { public async Task<string> Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } }
public sealed record ImmutableLocalPackage(string PackageId, string Version, string LocalPackageRoot); public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, IPackageMaterializer, ILastKnownGoodPackageResolver, IPackageRequirementCacheBinder
public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, ILastKnownGoodPackageResolver, IPackageRequirementCacheBinder
{ {
private sealed record Pointer(string Current, string? Previous); private sealed record Pointer(string Current, string? Previous);
private sealed record CacheBinding(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity); private sealed record CacheBinding(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity);
@@ -0,0 +1,55 @@
using System.Security.Cryptography;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class PackageLifecycleTests
{
private static PackageRequirement Req(string version) => new("Lifecycle.Package", "Plant3D", 2026, "x64", "pilot", version, "lifecycle-test", true);
private static PackageManifest Manifest(string version, byte[] a, byte[] b) => new(1, "Lifecycle.Package", version, "Plant3D", 2026, "x64", "entry.dll", [
new("entry.dll", a.Length, Convert.ToHexString(SHA256.HashData(a))),
new("payload.dat", b.Length, Convert.ToHexString(SHA256.HashData(b)))], new("none", ""), RolloutChannel: "pilot");
[Fact]
public async Task MaterializeDoesNotCreatePointersAndReturnsImmutableVersion()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source);
var a = new byte[] { 1, 2 }; var b = new byte[] { 3, 4, 5 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), a); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), b);
var store = new LocalPackageStore(root, new Sha256FileHasher());
try
{
var result = await store.MaterializeAsync(Manifest("1.0.0", a, b), source, CancellationToken.None);
Assert.True(File.Exists(Path.Combine(result.LocalPackageRoot, "package.manifest.json")));
Assert.False(File.Exists(Path.Combine(root, "Applications", "Lifecycle.Package", "current.json")));
Assert.False(File.Exists(Path.Combine(root, "Applications", "Lifecycle.Package", "previous.json")));
Assert.DoesNotContain("staging", result.LocalPackageRoot, StringComparison.OrdinalIgnoreCase);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task ActivationIsIdempotentAndRollbackRestoresPrevious()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
var a = new byte[] { 1 }; var b = new byte[] { 2 }; var c = new byte[] { 3 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), a); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), b);
try
{
var pa = await store.MaterializeAsync(Manifest("1.0.0", a, b), source, CancellationToken.None); var aa = await store.ActivateImmutableAsync(pa, Req("1.0.0"), CancellationToken.None); Assert.Equal("Activated", aa.Outcome);
var again = await store.ActivateImmutableAsync(pa, Req("1.0.0"), CancellationToken.None); Assert.Equal("AlreadyActiveExact", again.Outcome);
await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), c); var pb = await store.MaterializeAsync(Manifest("2.0.0", c, b), source, CancellationToken.None); var ab = await store.ActivateImmutableAsync(pb, Req("2.0.0"), CancellationToken.None); Assert.Equal("Activated", ab.Outcome);
var rollback = await store.RollbackAsync(ab.Receipt!, CancellationToken.None); Assert.Equal("RolledBack", rollback.Outcome); Assert.Equal("1.0.0", await store.GetCurrentVersionAsync("Lifecycle.Package", CancellationToken.None));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task OldReceiptCannotRollbackNewerActivation()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), bytes); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), bytes);
try { var a=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None); await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var b=await store.MaterializeAsync(Manifest("2.0.0",bytes,bytes),source,CancellationToken.None); var rb=await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None); var r=await store.RollbackAsync(rb.Receipt!,CancellationToken.None); Assert.Equal("RolledBack",r.Outcome); }
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
}