Files
launcher-next/Launcher.Next.Infrastructure/PilotExecutionService.cs
T

182 lines
15 KiB
C#

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
{
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 fixture = request.Configuration.AutomationMode is null ? null : ReadFixtureOrtho(request.Configuration.AutomationConfigurationPath);
var content = request.Configuration.AutomationMode is null
? $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\n"
: $"_.OPEN\n\"{fixture!.Replace("\"", "\\\"")}\"\n_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\nPHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\n";
var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
var plan = PilotPlanFactory.Create(request.Product, profile, request.Manifest, request.ManifestHash, request.PilotRoot, request.StartupScriptPath, scriptHash, safety);
var environment = new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = request.Configuration.PluginExecutionMode.ToString() };
if (!string.IsNullOrWhiteSpace(request.Configuration.ApplyPermitPath))
environment["P_REC_ANNOT_APPLY_PERMIT_PATH"] = request.Configuration.ApplyPermitPath;
if (request.Configuration.AutomationMode is not null)
{
environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot";
environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode;
environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = request.Configuration.AutomationConfigurationPath!;
}
return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, 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)!);
var script = request.Preparation.Configuration.AutomationMode is null
? scriptBuilder.BuildNetloadScript(versionDirectory, request.Preparation.Manifest.EntryAssembly)
: scriptBuilder.BuildAutomationScript(versionDirectory, request.Preparation.Manifest.EntryAssembly, ReadFixtureOrtho(request.Preparation.Configuration.AutomationConfigurationPath));
await File.WriteAllTextAsync(scriptPath, script, 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;
}
}
private static string ReadFixtureOrtho(string? automationConfigurationPath)
{
if (string.IsNullOrWhiteSpace(automationConfigurationPath) || !File.Exists(automationConfigurationPath)) throw new InvalidOperationException("AUTOMATION_CONFIG_MISSING");
using var json = JsonDocument.Parse(File.ReadAllText(automationConfigurationPath));
if (!json.RootElement.TryGetProperty("orthoDrawingPath", out var value) || string.IsNullOrWhiteSpace(value.GetString())) throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED");
return Path.GetFullPath(value.GetString()!);
}
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"); if (!File.Exists(path)) return null; await using var stream = File.OpenRead(path); return await JsonSerializer.DeserializeAsync<PersistedSession>(stream, cancellationToken: ct); }
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"); }
}