fix(launcher): require executable Linux hosts
This commit is contained in:
parent
10a712d66b
commit
ae2cbbee8c
5 changed files with 134 additions and 10 deletions
7
.github/workflows/headless-portability.yml
vendored
7
.github/workflows/headless-portability.yml
vendored
|
|
@ -135,6 +135,13 @@ jobs:
|
||||||
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
|
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
- name: Verify Linux headless host executable permission
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless
|
||||||
|
|
||||||
portable-launcher:
|
portable-launcher:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,14 @@ namespace AcDream.Launcher.Core.Orchestration;
|
||||||
public sealed class LauncherExecutableSet
|
public sealed class LauncherExecutableSet
|
||||||
{
|
{
|
||||||
private readonly Func<string, bool> _fileExists;
|
private readonly Func<string, bool> _fileExists;
|
||||||
|
private readonly Func<string, bool> _hasUnixExecutePermission;
|
||||||
|
|
||||||
public LauncherExecutableSet(
|
public LauncherExecutableSet(
|
||||||
string graphicalHostPath,
|
string graphicalHostPath,
|
||||||
string headlessHostPath,
|
string headlessHostPath,
|
||||||
string? workingDirectory = null,
|
string? workingDirectory = null,
|
||||||
Func<string, bool>? fileExists = null)
|
Func<string, bool>? fileExists = null,
|
||||||
|
Func<string, bool>? hasUnixExecutePermission = null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath);
|
||||||
|
|
@ -25,6 +27,8 @@ public sealed class LauncherExecutableSet
|
||||||
HeadlessHostPath = headlessHostPath;
|
HeadlessHostPath = headlessHostPath;
|
||||||
WorkingDirectory = workingDirectory;
|
WorkingDirectory = workingDirectory;
|
||||||
_fileExists = fileExists ?? File.Exists;
|
_fileExists = fileExists ?? File.Exists;
|
||||||
|
_hasUnixExecutePermission =
|
||||||
|
hasUnixExecutePermission ?? HasUnixExecutePermission;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GraphicalHostPath { get; }
|
public string GraphicalHostPath { get; }
|
||||||
|
|
@ -38,17 +42,25 @@ public sealed class LauncherExecutableSet
|
||||||
string path = mode == LaunchMode.Headless
|
string path = mode == LaunchMode.Headless
|
||||||
? HeadlessHostPath
|
? HeadlessHostPath
|
||||||
: GraphicalHostPath;
|
: GraphicalHostPath;
|
||||||
if (_fileExists(path))
|
|
||||||
{
|
|
||||||
return LauncherCapability.Available;
|
|
||||||
}
|
|
||||||
|
|
||||||
string host = mode == LaunchMode.Headless
|
string host = mode == LaunchMode.Headless
|
||||||
? "headless host"
|
? "headless host"
|
||||||
: "graphical client";
|
: "graphical client";
|
||||||
return LauncherCapability.Unavailable(
|
if (!_fileExists(path))
|
||||||
$"The co-deployed {host} is missing at '{path}'. Reinstall or update "
|
{
|
||||||
+ "the client before launching.");
|
return LauncherCapability.Unavailable(
|
||||||
|
$"The co-deployed {host} is missing at '{path}'. Reinstall or update "
|
||||||
|
+ "the client before launching.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsLinux() && !_hasUnixExecutePermission(path))
|
||||||
|
{
|
||||||
|
return LauncherCapability.Unavailable(
|
||||||
|
$"The co-deployed {host} at '{path}' exists but is not executable. "
|
||||||
|
+ "Restore its executable permission (for example, chmod +x) or "
|
||||||
|
+ "reinstall/update the client before launching.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return LauncherCapability.Available;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LauncherProcessSpec CreatePlaySpec(
|
public LauncherProcessSpec CreatePlaySpec(
|
||||||
|
|
@ -99,4 +111,27 @@ public sealed class LauncherExecutableSet
|
||||||
capability.Reason ?? "The selected launcher host is unavailable.");
|
capability.Reason ?? "The selected launcher host is unavailable.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool HasUnixExecutePermission(string path)
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const UnixFileMode executeBits =
|
||||||
|
UnixFileMode.UserExecute
|
||||||
|
| UnixFileMode.GroupExecute
|
||||||
|
| UnixFileMode.OtherExecute;
|
||||||
|
return (File.GetUnixFileMode(path) & executeBits) != 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Fail closed if the file vanished or its metadata cannot be read
|
||||||
|
// after the existence check. The next capability refresh retries.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ public sealed class LauncherExecutableSetTests : IDisposable
|
||||||
string headless = Path.Combine(_root, "acdream-headless" + suffix);
|
string headless = Path.Combine(_root, "acdream-headless" + suffix);
|
||||||
File.WriteAllText(graphical, string.Empty);
|
File.WriteAllText(graphical, string.Empty);
|
||||||
File.WriteAllText(headless, string.Empty);
|
File.WriteAllText(headless, string.Empty);
|
||||||
|
MakeExecutableOnLinux(graphical);
|
||||||
|
MakeExecutableOnLinux(headless);
|
||||||
|
|
||||||
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
|
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
|
||||||
|
|
||||||
|
|
@ -64,4 +66,78 @@ public sealed class LauncherExecutableSetTests : IDisposable
|
||||||
Assert.Throws<LauncherOperationException>(() =>
|
Assert.Throws<LauncherOperationException>(() =>
|
||||||
set.CreateProbeSpec("session.json"));
|
set.CreateProbeSpec("session.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LinuxRequiresExecutePermissionForBothCoDeployedHosts()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
string graphical = Path.Combine(_root, "AcDream.App");
|
||||||
|
string headless = Path.Combine(_root, "acdream-headless");
|
||||||
|
File.WriteAllText(graphical, string.Empty);
|
||||||
|
File.WriteAllText(headless, string.Empty);
|
||||||
|
UnixFileMode notExecutable = UnixFileMode.UserRead | UnixFileMode.UserWrite
|
||||||
|
| UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||||
|
File.SetUnixFileMode(graphical, notExecutable);
|
||||||
|
File.SetUnixFileMode(headless, notExecutable);
|
||||||
|
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
|
||||||
|
|
||||||
|
LauncherCapability gui = set.GetAvailability(LaunchMode.Gui);
|
||||||
|
LauncherCapability headlessCapability =
|
||||||
|
set.GetAvailability(LaunchMode.Headless);
|
||||||
|
|
||||||
|
Assert.False(gui.IsAvailable);
|
||||||
|
Assert.Contains("not executable", gui.Reason, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("chmod +x", gui.Reason, StringComparison.Ordinal);
|
||||||
|
Assert.False(headlessCapability.IsAvailable);
|
||||||
|
Assert.Contains("not executable", headlessCapability.Reason, StringComparison.Ordinal);
|
||||||
|
Assert.Throws<LauncherOperationException>(() =>
|
||||||
|
set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json"));
|
||||||
|
Assert.Throws<LauncherOperationException>(() =>
|
||||||
|
set.CreateProbeSpec("session.json"));
|
||||||
|
|
||||||
|
MakeExecutableOnLinux(graphical);
|
||||||
|
MakeExecutableOnLinux(headless);
|
||||||
|
|
||||||
|
Assert.True(set.GetAvailability(LaunchMode.GuiSelect).IsAvailable);
|
||||||
|
Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WindowsPreservesExistenceOnlyAvailability()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var set = new LauncherExecutableSet(
|
||||||
|
"graphical.exe",
|
||||||
|
"headless.exe",
|
||||||
|
fileExists: _ => true,
|
||||||
|
hasUnixExecutePermission: _ => false);
|
||||||
|
|
||||||
|
Assert.True(set.GetAvailability(LaunchMode.Gui).IsAvailable);
|
||||||
|
Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MakeExecutableOnLinux(string path)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(
|
||||||
|
path,
|
||||||
|
UnixFileMode.UserRead
|
||||||
|
| UnixFileMode.UserWrite
|
||||||
|
| UnixFileMode.UserExecute
|
||||||
|
| UnixFileMode.GroupRead
|
||||||
|
| UnixFileMode.GroupExecute
|
||||||
|
| UnixFileMode.OtherRead
|
||||||
|
| UnixFileMode.OtherExecute);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -518,7 +518,8 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
executables ?? new LauncherExecutableSet(
|
executables ?? new LauncherExecutableSet(
|
||||||
"gui-host",
|
"gui-host",
|
||||||
"headless-host",
|
"headless-host",
|
||||||
fileExists: _ => true),
|
fileExists: _ => true,
|
||||||
|
hasUnixExecutePermission: _ => true),
|
||||||
new LauncherInstallRecord("dats", "pak"),
|
new LauncherInstallRecord("dats", "pak"),
|
||||||
platform ?? WindowsCapabilities(),
|
platform ?? WindowsCapabilities(),
|
||||||
configService,
|
configService,
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,11 @@ public sealed class LauncherProjectBoundaryTests
|
||||||
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
|
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
|
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
|
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
|
||||||
|
workflow,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains("test -x \"$root/AcDream.App\"", workflow, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EvaluateProperty(string projectPath, string property)
|
private static string EvaluateProperty(string projectPath, string property)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue