feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
|
|
@ -0,0 +1,207 @@
|
|||
using System.Text;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Updates;
|
||||
|
||||
public sealed class ClientVersionStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-client-version-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
private readonly ApplicationPathSet _paths;
|
||||
private readonly string _rid;
|
||||
|
||||
public ClientVersionStoreTests()
|
||||
{
|
||||
_paths = UpdateTestData.Paths(_root);
|
||||
_rid = LauncherRuntimeIdentity.DetectRid();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AtomicPromotionPublishesStrictPointerAndRetainsPreviousForRollback()
|
||||
{
|
||||
var store = new ClientVersionStore(_paths);
|
||||
ClientVersionResolution first = await PromoteAsync(store, "1.0.0", "first");
|
||||
ClientVersionResolution second = await PromoteAsync(store, "2.0.0", "second");
|
||||
|
||||
Assert.True(first.IsVerified);
|
||||
Assert.True(second.IsVerified);
|
||||
Assert.Equal("2.0.0", second.Version!.Value);
|
||||
Assert.Equal("1.0.0", second.PreviousVersion);
|
||||
Assert.True(Directory.Exists(store.GetVersionDirectory(LauncherVersion.Parse("1.0.0"))));
|
||||
Assert.True(Directory.Exists(store.GetVersionDirectory(LauncherVersion.Parse("2.0.0"))));
|
||||
Assert.Empty(Directory.EnumerateFileSystemEntries(
|
||||
store.AppDirectory,
|
||||
".client-staging-*",
|
||||
SearchOption.TopDirectoryOnly));
|
||||
string pointer = await File.ReadAllTextAsync(store.CurrentPointerPath);
|
||||
Assert.Contains("\"schemaVersion\": 1", pointer, StringComparison.Ordinal);
|
||||
Assert.Contains("\"currentVersion\": \"2.0.0\"", pointer, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".client-staging", pointer, StringComparison.Ordinal);
|
||||
|
||||
ClientVersionResolution rolledBack = await store.RollbackAsync(_rid);
|
||||
|
||||
Assert.Equal("1.0.0", rolledBack.Version!.Value);
|
||||
Assert.Equal("2.0.0", rolledBack.PreviousVersion);
|
||||
Assert.Equal("first-gui", await File.ReadAllTextAsync(
|
||||
Path.Combine(rolledBack.Directory!, "AcDream.App" + ExecutableSuffix)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TornCurrentPointerRecoversLastDurablePointerAndOwnedTempResidue()
|
||||
{
|
||||
var store = new ClientVersionStore(_paths);
|
||||
_ = await PromoteAsync(store, "1.0.0", "first");
|
||||
_ = await PromoteAsync(store, "2.0.0", "second");
|
||||
string temp = Path.Combine(
|
||||
store.AppDirectory,
|
||||
$".current.json.{Guid.NewGuid():N}.tmp");
|
||||
await File.WriteAllTextAsync(temp, "partial temp");
|
||||
await File.WriteAllTextAsync(store.CurrentPointerPath, "{\"schemaVersion\":1,");
|
||||
|
||||
var recoveredStore = new ClientVersionStore(_paths);
|
||||
ClientVersionResolution recovered = await recoveredStore.LoadAndRecoverAsync(_rid);
|
||||
|
||||
Assert.True(recovered.IsVerified);
|
||||
Assert.Equal("1.0.0", recovered.Version!.Value);
|
||||
Assert.Contains("Recovered", recovered.Status, StringComparison.Ordinal);
|
||||
Assert.False(File.Exists(temp));
|
||||
Assert.Contains("\"currentVersion\": \"1.0.0\"", await File.ReadAllTextAsync(
|
||||
recoveredStore.CurrentPointerPath), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorruptActiveInstallFailsClosedButVerifiedPreviousCanRollback()
|
||||
{
|
||||
var store = new ClientVersionStore(_paths);
|
||||
_ = await PromoteAsync(store, "1.0.0", "first");
|
||||
ClientVersionResolution second = await PromoteAsync(store, "2.0.0", "second");
|
||||
string graphical = Path.Combine(second.Directory!, "AcDream.App" + ExecutableSuffix);
|
||||
await File.WriteAllTextAsync(graphical, "tampered!!");
|
||||
|
||||
var restarted = new ClientVersionStore(_paths);
|
||||
ClientVersionResolution invalid = await restarted.LoadAndRecoverAsync(_rid);
|
||||
|
||||
Assert.Equal(ClientVersionState.Invalid, invalid.State);
|
||||
Assert.Contains("corrupt", invalid.Status, StringComparison.OrdinalIgnoreCase);
|
||||
ClientVersionResolution rolledBack = await restarted.RollbackAsync(_rid);
|
||||
Assert.Equal("1.0.0", rolledBack.Version!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrictPointerAndInstallRecordsRejectUnknownUnrecordedAndWrongRidState()
|
||||
{
|
||||
var store = new ClientVersionStore(_paths);
|
||||
ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "strict");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(installed.Directory!, "unrecorded.dll"),
|
||||
"unexpected");
|
||||
|
||||
var restarted = new ClientVersionStore(_paths);
|
||||
ClientVersionResolution unrecorded = await restarted.LoadAndRecoverAsync(_rid);
|
||||
Assert.Equal(ClientVersionState.Invalid, unrecorded.State);
|
||||
Assert.Contains("unrecorded", unrecorded.Status, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
File.Delete(Path.Combine(installed.Directory!, "unrecorded.dll"));
|
||||
string installPath = ClientVersionStore.GetMetadataPath(installed.Directory!);
|
||||
string install = await File.ReadAllTextAsync(installPath);
|
||||
await File.WriteAllTextAsync(
|
||||
installPath,
|
||||
install.Replace(
|
||||
"\"schemaVersion\": 1",
|
||||
"\"schemaVersion\": 1,\"schemaVersion\": 1",
|
||||
StringComparison.Ordinal));
|
||||
ClientVersionResolution duplicate = await restarted.LoadAndRecoverAsync(_rid);
|
||||
Assert.Equal(ClientVersionState.Invalid, duplicate.State);
|
||||
Assert.Contains("Duplicate", duplicate.Status, StringComparison.Ordinal);
|
||||
await File.WriteAllTextAsync(installPath, install);
|
||||
|
||||
string current = await File.ReadAllTextAsync(store.CurrentPointerPath);
|
||||
await File.WriteAllTextAsync(
|
||||
store.CurrentPointerPath,
|
||||
current.TrimEnd().TrimEnd('}') + ",\"unknown\":true}");
|
||||
ClientVersionResolution unknown = await restarted.LoadAndRecoverAsync(_rid);
|
||||
Assert.Equal(ClientVersionState.Invalid, unknown.State);
|
||||
|
||||
await File.WriteAllTextAsync(store.CurrentPointerPath, current);
|
||||
string otherRid = _rid == "win-x64" ? "linux-x64" : "win-x64";
|
||||
ClientVersionResolution wrongRid = await restarted.LoadAndRecoverAsync(otherRid);
|
||||
Assert.Equal(ClientVersionState.Invalid, wrongRid.State);
|
||||
Assert.Contains("RID", wrongRid.Status, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DynamicExecutableResolverTracksOnlyVerifiedCurrentVersion()
|
||||
{
|
||||
var store = new ClientVersionStore(_paths);
|
||||
LauncherExecutableSet executables = LauncherExecutableSet.FromCurrentVersionStore(store);
|
||||
Assert.False(executables.GetAvailability(LaunchMode.Gui).IsAvailable);
|
||||
|
||||
ClientVersionResolution first = await PromoteAsync(store, "1.0.0", "one");
|
||||
Assert.Equal(first.Directory, executables.WorkingDirectory);
|
||||
Assert.Equal(
|
||||
Path.Combine(first.Directory!, "AcDream.App" + ExecutableSuffix),
|
||||
executables.CreatePlaySpec(LaunchMode.Gui, "session.json").ExecutablePath);
|
||||
|
||||
ClientVersionResolution second = await PromoteAsync(store, "2.0.0", "two");
|
||||
Assert.Equal(second.Directory, executables.WorkingDirectory);
|
||||
Assert.Equal(
|
||||
Path.Combine(second.Directory!, "acdream-headless" + ExecutableSuffix),
|
||||
executables.CreateProbeSpec("session.json").ExecutablePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistingSemanticVersionCannotReplaceActiveContentInPlace()
|
||||
{
|
||||
var store = new ClientVersionStore(_paths);
|
||||
_ = await PromoteAsync(store, "1.0.0", "original");
|
||||
|
||||
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
PromoteAsync(store, "1.0.0", "different"));
|
||||
|
||||
Assert.Contains("active client version", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
ClientVersionResolution resolution = await store.LoadAndRecoverAsync(_rid);
|
||||
Assert.Equal("original-gui", await File.ReadAllTextAsync(
|
||||
Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix)));
|
||||
}
|
||||
|
||||
private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal)
|
||||
? ".exe"
|
||||
: string.Empty;
|
||||
|
||||
private async Task<ClientVersionResolution> PromoteAsync(
|
||||
ClientVersionStore store,
|
||||
string versionText,
|
||||
string marker)
|
||||
{
|
||||
byte[] archive = UpdateTestData.ClientZip(_rid, marker);
|
||||
string zipPath = Path.Combine(_root, $"{versionText}-{marker}.zip");
|
||||
Directory.CreateDirectory(_root);
|
||||
await File.WriteAllBytesAsync(zipPath, archive);
|
||||
string staging = store.CreateClientStagingDirectory(Guid.NewGuid());
|
||||
IReadOnlyList<ExtractedFileRecord> files = await new SafeZipExtractor()
|
||||
.ExtractAsync(zipPath, staging);
|
||||
using UpdateSessionBarrier.ExclusiveLease lease = store.Barrier.AcquireExclusive();
|
||||
return await store.PromoteAndActivateUnderLeaseAsync(
|
||||
staging,
|
||||
LauncherVersion.Parse(versionText),
|
||||
_rid,
|
||||
new ReleaseArtifact(
|
||||
new Uri($"https://example.test/{versionText}.zip"),
|
||||
UpdateTestData.Sha256(archive),
|
||||
archive.LongLength),
|
||||
files);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue