zcbot/windows-node/Zcbot.WindowsNode/Runtime/ManagedRuntimeInstaller.cs

216 lines
7.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.ComponentModel;
using System.Diagnostics;
using System.Text;
namespace Zcbot.WindowsNode;
internal static class ManagedRuntimeInstaller
{
private const string DefaultIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple/";
private static readonly ProcessSupervisor ProcessSupervisor = new();
internal static async Task<RuntimeInstallResult> InstallAsync(
SoftwareDefinition definition,
IProgress<string> progress,
CancellationToken cancellationToken)
{
if (AdapterCatalog.HasActiveExecution)
{
throw new InvalidOperationException(
$"{definition.DisplayName} 仍有任务在执行,暂不能更换运行环境。");
}
var location = SoftwareLocationService.ResolveLocation(definition);
if (!location.Available)
{
throw new InvalidOperationException(
$"尚未检测到 {definition.DisplayName}{location.Detail}");
}
var requirements = ResolveBundledFile(definition.RequirementsRelativePath);
var root = NodePaths.ForCurrentMachine().RootDirectory;
var runtimes = Path.Combine(root, "runtimes");
Directory.CreateDirectory(runtimes);
NodeConfigStore.RestrictDirectory(root);
var runtime = Path.Combine(runtimes, definition.RuntimeId);
var staging = Path.Combine(
runtimes,
$".{definition.RuntimeId}.installing-{Guid.NewGuid():N}");
var backup = Path.Combine(
runtimes,
$".{definition.RuntimeId}.rollback-{Guid.NewGuid():N}");
var stagingPython = Path.Combine(staging, "Scripts", "python.exe");
var movedExisting = false;
try
{
progress.Report("正在查找 Python 3.12…");
var bootstrap = await FindPython312Async(cancellationToken);
progress.Report("正在创建隔离运行环境…");
await RunAsync(
bootstrap.FileName,
bootstrap.PrefixArguments.Concat(["-m", "venv", staging]),
AppContext.BaseDirectory,
progress,
cancellationToken);
if (!File.Exists(stagingPython))
{
throw new InvalidDataException("Python venv 未生成 Scripts\\python.exe。");
}
await VerifyPython312Async(stagingPython, cancellationToken);
progress.Report("正在安装 adapter 依赖…");
var indexUrl = Environment.GetEnvironmentVariable("ZCBOT_PIP_INDEX_URL");
if (string.IsNullOrWhiteSpace(indexUrl))
{
indexUrl = DefaultIndexUrl;
}
await RunAsync(
stagingPython,
["-m", "pip", "install", "--index-url", indexUrl, "--requirement", requirements],
AppContext.BaseDirectory,
progress,
cancellationToken);
if (Directory.Exists(runtime))
{
Directory.Move(runtime, backup);
movedExisting = true;
}
Directory.Move(staging, runtime);
if (movedExisting)
{
Directory.Delete(backup, recursive: true);
}
NodeConfigStore.RestrictDirectory(runtime);
return new RuntimeInstallResult(
Path.Combine(runtime, "Scripts", "python.exe"),
$"{definition.DisplayName} 运行环境安装完成。");
}
catch
{
if (movedExisting && !Directory.Exists(runtime) && Directory.Exists(backup))
{
Directory.Move(backup, runtime);
}
throw;
}
finally
{
DeleteDirectoryIfPresent(staging);
DeleteDirectoryIfPresent(backup);
}
}
private static string ResolveBundledFile(string relativePath)
{
var root = Path.GetFullPath(AppContext.BaseDirectory);
var path = Path.GetFullPath(Path.Combine(root, relativePath));
if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(path))
{
throw new InvalidDataException("Adapter requirements 文件缺失或路径非法。");
}
return path;
}
private static async Task<BootstrapPython> FindPython312Async(
CancellationToken cancellationToken)
{
var candidates = new[]
{
new BootstrapPython("py.exe", ["-3.12"]),
new BootstrapPython("python.exe", []),
};
foreach (var candidate in candidates)
{
try
{
await RunAsync(
candidate.FileName,
candidate.PrefixArguments.Concat(
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"]),
AppContext.BaseDirectory,
progress: null,
cancellationToken);
return candidate;
}
catch (Exception exception) when (
exception is InvalidOperationException or Win32Exception)
{
// Try the next fixed Python 3.12 discovery method.
}
}
throw new InvalidOperationException(
"未找到 Python 3.12。请先安装 Python 3.12 并启用 py launcher 或加入 PATH。");
}
private static Task VerifyPython312Async(string python, CancellationToken cancellationToken) =>
RunAsync(
python,
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"],
AppContext.BaseDirectory,
progress: null,
cancellationToken);
private static async Task RunAsync(
string filename,
IEnumerable<string> arguments,
string workingDirectory,
IProgress<string>? progress,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = filename,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
startInfo.Environment["PYTHONUTF8"] = "1";
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
using var process = ProcessSupervisor.Start(
startInfo, "运行环境安装进程未启动。");
var stdout = ProcessSupervisor.CaptureOutputAsync(process.StandardOutput, progress);
var stderr = ProcessSupervisor.CaptureOutputAsync(process.StandardError, progress);
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
await ProcessSupervisor.TerminateTreeAsync(process);
throw;
}
await Task.WhenAll(stdout, stderr);
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"运行环境安装进程失败,退出码 {process.ExitCode}。");
}
}
private static void DeleteDirectoryIfPresent(string path)
{
try
{
if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// A successful runtime swap must not be reported as failed only because
// antivirus software still holds a handle to the disposable directory.
}
}
private sealed record BootstrapPython(
string FileName,
IReadOnlyList<string> PrefixArguments);
}