feat: complete pilot CLI session and rollback lifecycle
This commit is contained in:
@@ -1,13 +1,157 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Launcher.Next.Core;
|
||||
|
||||
namespace Launcher.Next.Infrastructure;
|
||||
public sealed class PilotExecutionService(IPackageManifestValidator validator, IPilotRootPolicy rootPolicy, INextPilotSafetyGate gate, IFileHasher hasher, IStartupScriptBuilder scriptBuilder, IExecutionPermitService permits, IAutoCadProcessProbe probe, IOperatingSystemProcessInvoker invoker) : IPilotExecutionService
|
||||
|
||||
public sealed class PilotExecutionService(
|
||||
IPackageManifestValidator validator,
|
||||
IPilotRootPolicy rootPolicy,
|
||||
INextPilotSafetyGate gate,
|
||||
IFileHasher hasher,
|
||||
IStartupScriptBuilder scriptBuilder,
|
||||
IExecutionPermitService permits,
|
||||
IAutoCadProcessProbe probe,
|
||||
IOperatingSystemProcessInvoker invoker) : IPilotExecutionService
|
||||
{
|
||||
public Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest r, CancellationToken ct)
|
||||
{ ct.ThrowIfCancellationRequested(); var root=rootPolicy.Validate(r.PilotRoot); var validation=validator.Validate(r.Manifest,2026,"x64"); var hashes=ValidateSource(r.Manifest,r.PackageSourceRoot,ct).GetAwaiter().GetResult(); var profile=new AutoCadProfileCandidate("",false,true,["legacy contract: no profile argument"]); var safety=gate.Evaluate(new(r.Configuration,r.Product,profile,root,validation,hashes,false,probe.IsAcadRunning()),false); if(!safety.IsAllowed)return Task.FromResult(new PilotPreparationResult(null,safety)); var futureEntry=Path.Combine(r.PilotRoot,"packages",r.Manifest.PackageId,"versions",r.Manifest.Version,r.Manifest.EntryAssembly); var content="._NETLOAD\n\""+futureEntry.Replace("\\","\\\\")+"\"\n"; var scriptHash=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); var plan=PilotPlanFactory.Create(r.Product,profile,r.Manifest,r.ManifestHash,r.PilotRoot,r.StartupScriptPath,scriptHash,safety); return Task.FromResult(new PilotPreparationResult(plan,safety)); }
|
||||
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest r, CancellationToken ct)
|
||||
{ var plan=r.Plan; var root=rootPolicy.Validate(r.Preparation.PilotRoot); var valid=validator.Validate(r.Preparation.Manifest,2026,"x64"); var hashes=await ValidateSource(r.Preparation.Manifest,r.Preparation.PackageSourceRoot,ct); var context=new PilotSafetyContext(r.Preparation.Configuration,r.Preparation.Product,new("",false,true,["legacy contract"]),root,valid,hashes,false,probe.IsAcadRunning()); var safety=gate.Evaluate(context,true); if(!safety.IsAllowed)return new(false,null,safety.Violations.First(),null,null); if(!permits.Validate(r.Permit,plan,DateTimeOffset.UtcNow))return new(false,null,"EXECUTION_PERMIT_MISSING",null,null); var version=Path.Combine(plan.PilotRoot,"packages",plan.PackageId,"versions",plan.PackageVersion); var staging=Path.Combine(plan.PilotRoot,"staging",Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging); try { foreach(var f in r.Preparation.Manifest.Files){ct.ThrowIfCancellationRequested();var src=Path.Combine(r.Preparation.PackageSourceRoot,f.Path);var dst=Path.Combine(staging,f.Path);Directory.CreateDirectory(Path.GetDirectoryName(dst)!);File.Copy(src,dst,true);if(!string.Equals(await hasher.Sha256Async(dst,ct),f.Sha256,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("HASH_VALIDATION_FAILED");} Directory.CreateDirectory(Path.GetDirectoryName(version)!);if(!Directory.Exists(version))Directory.Move(staging,version);else Directory.Delete(staging,true); Directory.CreateDirectory(Path.GetDirectoryName(plan.StartupScriptPath)!);await File.WriteAllTextAsync(plan.StartupScriptPath,scriptBuilder.BuildNetloadScript(version,r.Preparation.Manifest.EntryAssembly),ct); var guarded=new GuardedAutodeskProcessLauncher(gate,probe,invoker);var result=guarded.Start(context,plan,r.Permit,permits);return new(result.Started,result.ProcessId,result.BlockCode,version,plan.StartupScriptPath);}catch{if(Directory.Exists(staging))Directory.Delete(staging,true);throw;} }
|
||||
public Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest r,CancellationToken ct){try{if(File.Exists(r.StartupScriptPath))File.Delete(r.StartupScriptPath);if(r.ActivatedVersion is not null){var d=Path.Combine(r.PilotRoot,"packages",r.PackageId,"versions",r.ActivatedVersion);if(Directory.Exists(d))Directory.Delete(d,true);}return Task.FromResult(new PilotRollbackResult(true,null));}catch{return Task.FromResult(new PilotRollbackResult(false,"ROLLBACK_FAILED"));}}
|
||||
private async Task<bool> ValidateSource(PackageManifest m,string root,CancellationToken ct){foreach(var f in m.Files){var p=Path.Combine(root,f.Path);if(!File.Exists(p)||!string.Equals(await hasher.Sha256Async(p,ct),f.Sha256,StringComparison.OrdinalIgnoreCase))return false;}return true;}
|
||||
private sealed record Pointer(string Version);
|
||||
private sealed record ActivationSnapshot(
|
||||
string TransactionId, string PackageId, string PackageVersion,
|
||||
string? CurrentContent, string? CurrentSha256,
|
||||
string? PreviousContent, string? PreviousSha256,
|
||||
string VersionDirectory, string StartupScriptPath, string StagingPath);
|
||||
private sealed record PersistedSession(
|
||||
int SchemaVersion, string SessionId, string TransactionId, string State,
|
||||
string PackageId, string PackageVersion, string ManifestSha256,
|
||||
string PilotRoot, string VersionDirectory, string StartupScriptPath,
|
||||
string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc,
|
||||
int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc,
|
||||
string? LastErrorCode, string? LastErrorMessage);
|
||||
|
||||
public async Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var root = rootPolicy.Validate(request.PilotRoot);
|
||||
var validation = validator.Validate(request.Manifest, 2026, "x64");
|
||||
var hashes = await ValidateSource(request.Manifest, request.PackageSourceRoot, cancellationToken);
|
||||
var profile = new AutoCadProfileCandidate(string.Empty, false, true, ["Confirmed Plant 3D launch contract has no /p argument."]);
|
||||
var safety = gate.Evaluate(new(request.Configuration, request.Product, profile, root, validation, hashes, false, probe.IsAcadRunning()), false);
|
||||
if (!safety.IsAllowed) return new(null, safety);
|
||||
|
||||
var futureEntry = Path.Combine(request.PilotRoot, "packages", request.Manifest.PackageId, "versions", request.Manifest.Version, request.Manifest.EntryAssembly);
|
||||
var content = $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\n";
|
||||
var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
|
||||
return new(PilotPlanFactory.Create(request.Product, profile, request.Manifest, request.ManifestHash, request.PilotRoot, request.StartupScriptPath, scriptHash, safety), safety);
|
||||
}
|
||||
|
||||
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = request.Plan;
|
||||
var root = rootPolicy.Validate(request.Preparation.PilotRoot);
|
||||
var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64");
|
||||
var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken);
|
||||
var context = new PilotSafetyContext(request.Preparation.Configuration, request.Preparation.Product,
|
||||
new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning());
|
||||
var decision = gate.Evaluate(context, true);
|
||||
if (!decision.IsAllowed) return new(false, null, decision.Violations.First(), null, null);
|
||||
if (!string.Equals(plan.PlanId, PilotPlanFactory.Create(request.Preparation.Product, context.Profile!, request.Preparation.Manifest, request.Preparation.ManifestHash, request.Preparation.PilotRoot, request.Preparation.StartupScriptPath, plan.StartupScriptSha256, plan.SafetyDecision).PlanId, StringComparison.Ordinal))
|
||||
return new(false, null, "PILOT_PLAN_CHANGED", null, null);
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString("N");
|
||||
var transactionId = Guid.NewGuid().ToString("N");
|
||||
var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId);
|
||||
var versionDirectory = plan.VersionDirectory;
|
||||
var staging = Path.Combine(plan.PilotRoot, "staging", transactionId);
|
||||
var scriptPath = Path.Combine(plan.PilotRoot, "startup", sessionId + ".scr");
|
||||
var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
|
||||
var currentPath = Path.Combine(packageRoot, "current.json");
|
||||
var previousPath = Path.Combine(packageRoot, "previous.json");
|
||||
var current = await ReadTextOrNull(currentPath, cancellationToken);
|
||||
var previous = await ReadTextOrNull(previousPath, cancellationToken);
|
||||
var snapshot = new ActivationSnapshot(transactionId, plan.PackageId, plan.PackageVersion, current, Hash(current), previous, Hash(previous), versionDirectory, scriptPath, staging);
|
||||
await WriteJsonAtomic(snapshotPath, snapshot, cancellationToken);
|
||||
await WriteSession(plan.PilotRoot, new PersistedSession(1, sessionId, transactionId, "Activating", plan.PackageId, plan.PackageVersion, plan.ManifestSha256, plan.PilotRoot, versionDirectory, scriptPath, snapshotPath, null, null, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null), cancellationToken);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(staging);
|
||||
foreach (var file in request.Preparation.Manifest.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var source = Path.Combine(request.Preparation.PackageSourceRoot, file.Path);
|
||||
var target = Path.GetFullPath(Path.Combine(staging, file.Path));
|
||||
if (!target.StartsWith(Path.GetFullPath(staging) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(source, target, true);
|
||||
if (!string.Equals(await hasher.Sha256Async(target, cancellationToken), file.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_VALIDATION_FAILED");
|
||||
}
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(versionDirectory)!);
|
||||
if (Directory.Exists(versionDirectory)) Directory.Delete(staging, true); else Directory.Move(staging, versionDirectory);
|
||||
await WriteTextAtomic(previousPath, current ?? string.Empty, cancellationToken, current is not null);
|
||||
await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
|
||||
await File.WriteAllTextAsync(scriptPath, scriptBuilder.BuildNetloadScript(versionDirectory, request.Preparation.Manifest.EntryAssembly), cancellationToken);
|
||||
await WriteSession(plan.PilotRoot, (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "ProcessStarting", UpdatedAtUtc = DateTimeOffset.UtcNow }, cancellationToken);
|
||||
var result = new GuardedAutodeskProcessLauncher(gate, probe, invoker).Start(context, plan with { StartupScriptPath = scriptPath, Process = plan.Process with { Arguments = ["/product", "PLNT3D", "/language", "ru-RU", "/b", scriptPath], WorkingDirectory = plan.PilotRoot } }, request.Permit, permits);
|
||||
if (!result.Started)
|
||||
{
|
||||
await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken);
|
||||
return new(false, null, result.BlockCode, versionDirectory, scriptPath, sessionId, snapshotPath);
|
||||
}
|
||||
var session = (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "WaitingForRuntimeCheck", ProcessId = result.ProcessId, ProcessStartUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow };
|
||||
await WriteSession(plan.PilotRoot, session, cancellationToken);
|
||||
return new(true, result.ProcessId, null, versionDirectory, scriptPath, sessionId, snapshotPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MarkFailed(plan.PilotRoot, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var session = await ReadSession(request.PilotRoot, cancellationToken);
|
||||
var snapshotPath = request.ActivationSnapshotPath ?? session?.ActivationSnapshotPath;
|
||||
if (string.IsNullOrWhiteSpace(snapshotPath) || !File.Exists(snapshotPath)) return new(false, "ACTIVATION_SNAPSHOT_MISSING");
|
||||
var snapshot = await JsonSerializer.DeserializeAsync<ActivationSnapshot>(File.OpenRead(snapshotPath), cancellationToken: cancellationToken);
|
||||
if (snapshot is null) return new(false, "ACTIVATION_SNAPSHOT_INVALID");
|
||||
if (session?.ProcessId is int pid && probe.IsAcadRunning() && IsProcessRunning(pid)) return new(false, "PROCESS_STILL_RUNNING");
|
||||
var packageRoot = Path.Combine(request.PilotRoot, "packages", snapshot.PackageId);
|
||||
await Restore(Path.Combine(packageRoot, "current.json"), snapshot.CurrentContent, snapshot.CurrentSha256, cancellationToken);
|
||||
await Restore(Path.Combine(packageRoot, "previous.json"), snapshot.PreviousContent, snapshot.PreviousSha256, cancellationToken);
|
||||
if (File.Exists(snapshot.StartupScriptPath)) File.Delete(snapshot.StartupScriptPath);
|
||||
if (Directory.Exists(snapshot.StagingPath)) Directory.Delete(snapshot.StagingPath, true);
|
||||
if (Directory.Exists(snapshot.VersionDirectory)) Directory.Delete(snapshot.VersionDirectory, true);
|
||||
if (session is not null) await WriteSession(request.PilotRoot, session with { State = "RolledBack", UpdatedAtUtc = DateTimeOffset.UtcNow, LastErrorCode = null, LastErrorMessage = null }, cancellationToken);
|
||||
return new(true, null);
|
||||
}
|
||||
catch { return new(false, "ROLLBACK_FAILED"); }
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateSource(PackageManifest manifest, string root, CancellationToken ct)
|
||||
{
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
var path = Path.Combine(root, file.Path);
|
||||
if (!File.Exists(path) || !string.Equals(await hasher.Sha256Async(path, ct), file.Sha256, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static bool IsProcessRunning(int pid) { try { return !System.Diagnostics.Process.GetProcessById(pid).HasExited; } catch { return false; } }
|
||||
private static string? Hash(string? text) => text is null ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
|
||||
private static async Task<string?> ReadTextOrNull(string path, CancellationToken ct) => File.Exists(path) ? await File.ReadAllTextAsync(path, ct) : null;
|
||||
private static async Task WriteTextAtomic(string path, string content, CancellationToken ct, bool exists)
|
||||
{ if (!exists) { if (File.Exists(path)) File.Delete(path); return; } Directory.CreateDirectory(Path.GetDirectoryName(path)!); var temp = path + ".tmp"; await File.WriteAllTextAsync(temp, content, ct); File.Move(temp, path, true); }
|
||||
private static async Task WriteJsonAtomic<T>(string path, T value, CancellationToken ct) => await WriteTextAtomic(path, JsonSerializer.Serialize(value), ct, true);
|
||||
private static async Task<PersistedSession?> ReadSession(string root, CancellationToken ct)
|
||||
{ var path = Path.Combine(root, "state", "pilot-session.json"); return File.Exists(path) ? await JsonSerializer.DeserializeAsync<PersistedSession>(File.OpenRead(path), cancellationToken: ct) : null; }
|
||||
private static Task WriteSession(string root, PersistedSession session, CancellationToken ct) => WriteJsonAtomic(Path.Combine(root, "state", "pilot-session.json"), session, ct);
|
||||
private static async Task MarkFailed(string root, string code, CancellationToken ct)
|
||||
{ var s = await ReadSession(root, ct); if (s is not null) await WriteSession(root, s with { State = "Failed", UpdatedAtUtc = DateTimeOffset.UtcNow, LastErrorCode = code, LastErrorMessage = code }, ct); }
|
||||
private static async Task Restore(string path, string? content, string? sha, CancellationToken ct)
|
||||
{ if (content is null) { if (File.Exists(path)) File.Delete(path); return; } if (!string.Equals(Hash(content), sha, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("SNAPSHOT_HASH_INVALID"); await WriteTextAtomic(path, content, ct, true); if (!string.Equals(Hash(await File.ReadAllTextAsync(path, ct)), sha, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("POINTER_RESTORE_FAILED"); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user