feat(headless): establish portable Linux host boundary
Add the presentation-free acdream-headless executable, strict no-connect configuration validation, dependency and assembly guards, and a Windows/Ubuntu CI lane that builds and tests only the portable runtime closure. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
953c469cac
commit
aada8a37c1
14 changed files with 613 additions and 8 deletions
22
src/AcDream.Headless/AcDream.Headless.csproj
Normal file
22
src/AcDream.Headless/AcDream.Headless.csproj
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>acdream-headless</AssemblyName>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AcDream.Headless.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The production headless process enters through the same Runtime root
|
||||
as the graphical client. Presentation, UI, native-window, GL, and
|
||||
audio projects are forbidden here and enforced by tests. -->
|
||||
<ProjectReference Include="..\AcDream.Runtime\AcDream.Runtime.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
18
src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
Normal file
18
src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.Headless.Configuration;
|
||||
|
||||
internal sealed class HeadlessConfiguration
|
||||
{
|
||||
[JsonRequired]
|
||||
public int Version { get; init; }
|
||||
|
||||
[JsonRequired]
|
||||
public List<HeadlessSessionDescriptor?> Sessions { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class HeadlessSessionDescriptor
|
||||
{
|
||||
[JsonRequired]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace AcDream.Headless.Configuration;
|
||||
|
||||
internal sealed class HeadlessConfigurationException : Exception
|
||||
{
|
||||
internal HeadlessConfigurationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.Headless.Configuration;
|
||||
|
||||
internal static class HeadlessConfigurationLoader
|
||||
{
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
};
|
||||
|
||||
internal static HeadlessConfiguration Load(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
using FileStream stream = File.OpenRead(fullPath);
|
||||
HeadlessConfiguration? configuration =
|
||||
JsonSerializer.Deserialize<HeadlessConfiguration>(
|
||||
stream,
|
||||
Options);
|
||||
|
||||
if (configuration is null)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
"The configuration document is empty.");
|
||||
}
|
||||
|
||||
if (configuration.Version != CurrentVersion)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Unsupported configuration version {configuration.Version}; "
|
||||
+ $"expected {CurrentVersion}.");
|
||||
}
|
||||
|
||||
if (configuration.Sessions is null)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
"sessions must be an array.");
|
||||
}
|
||||
|
||||
var sessionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (HeadlessSessionDescriptor? session in configuration.Sessions)
|
||||
{
|
||||
if (session is null
|
||||
|| string.IsNullOrWhiteSpace(session.Id))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
"Every session requires a non-empty id.");
|
||||
}
|
||||
|
||||
if (!sessionIds.Add(session.Id))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Duplicate session id '{session.Id}'.");
|
||||
}
|
||||
}
|
||||
|
||||
return configuration;
|
||||
}
|
||||
}
|
||||
6
src/AcDream.Headless/HeadlessAssemblyMarker.cs
Normal file
6
src/AcDream.Headless/HeadlessAssemblyMarker.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace AcDream.Headless;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the presentation-free production host assembly.
|
||||
/// </summary>
|
||||
internal static class HeadlessAssemblyMarker;
|
||||
82
src/AcDream.Headless/HeadlessEntryPoint.cs
Normal file
82
src/AcDream.Headless/HeadlessEntryPoint.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using AcDream.Headless.Configuration;
|
||||
|
||||
namespace AcDream.Headless;
|
||||
|
||||
internal static class HeadlessEntryPoint
|
||||
{
|
||||
private const string HelpText =
|
||||
"""
|
||||
acdream-headless
|
||||
|
||||
Usage:
|
||||
acdream-headless --help
|
||||
acdream-headless validate --config <path>
|
||||
|
||||
Commands:
|
||||
validate Validate a versioned headless configuration without connecting.
|
||||
|
||||
Secrets are never accepted on the command line.
|
||||
""";
|
||||
|
||||
internal static int Run(
|
||||
IReadOnlyList<string> arguments,
|
||||
TextWriter output,
|
||||
TextWriter error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
|
||||
if (arguments.Count == 0
|
||||
|| IsHelp(arguments[0]))
|
||||
{
|
||||
output.WriteLine(HelpText);
|
||||
return (int)HeadlessExitCode.Success;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
arguments[0],
|
||||
"validate",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
error.WriteLine("Unknown command. Run --help for usage.");
|
||||
return (int)HeadlessExitCode.UsageError;
|
||||
}
|
||||
|
||||
if (arguments.Count != 3
|
||||
|| !string.Equals(
|
||||
arguments[1],
|
||||
"--config",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
error.WriteLine(
|
||||
"validate requires exactly: --config <path>");
|
||||
return (int)HeadlessExitCode.UsageError;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
HeadlessConfiguration configuration =
|
||||
HeadlessConfigurationLoader.Load(arguments[2]);
|
||||
output.WriteLine(
|
||||
$"Configuration valid: version {configuration.Version}, "
|
||||
+ $"{configuration.Sessions.Count} session(s).");
|
||||
return (int)HeadlessExitCode.Success;
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is IOException
|
||||
or UnauthorizedAccessException
|
||||
or ArgumentException
|
||||
or NotSupportedException
|
||||
or System.Text.Json.JsonException
|
||||
or HeadlessConfigurationException)
|
||||
{
|
||||
error.WriteLine($"Configuration invalid: {exception.Message}");
|
||||
return (int)HeadlessExitCode.ConfigurationError;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHelp(string argument) =>
|
||||
string.Equals(argument, "--help", StringComparison.Ordinal)
|
||||
|| string.Equals(argument, "-h", StringComparison.Ordinal);
|
||||
}
|
||||
8
src/AcDream.Headless/HeadlessExitCode.cs
Normal file
8
src/AcDream.Headless/HeadlessExitCode.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace AcDream.Headless;
|
||||
|
||||
internal enum HeadlessExitCode
|
||||
{
|
||||
Success = 0,
|
||||
UsageError = 2,
|
||||
ConfigurationError = 3,
|
||||
}
|
||||
3
src/AcDream.Headless/Program.cs
Normal file
3
src/AcDream.Headless/Program.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
using AcDream.Headless;
|
||||
|
||||
return HeadlessEntryPoint.Run(args, Console.Out, Console.Error);
|
||||
Loading…
Add table
Add a link
Reference in a new issue