216 lines
7.9 KiB
C#
216 lines
7.9 KiB
C#
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);
|
||
}
|