ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
27 changed files with 145336 additions and 0 deletions
2
packages/fsharp-runner/.gitignore
vendored
Normal file
2
packages/fsharp-runner/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
**/bin/
|
||||
**/obj/
|
||||
7
packages/fsharp-runner/Directory.Build.props
Normal file
7
packages/fsharp-runner/Directory.Build.props
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<Project>
|
||||
<PropertyGroup>
|
||||
<AnalysisLevel>latest-all</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
63
packages/fsharp-runner/Dockerfile.bootstrap
Normal file
63
packages/fsharp-runner/Dockerfile.bootstrap
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
# Bootstrap only when no F# Runner image is available; Project Delivery owns the canonical build.
|
||||
|
||||
ARG SDK=10.0.403-noble@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b
|
||||
ARG RUNTIME=10.0.14@sha256:a3f9f81954b9c810af2ed18849716c8dc74a4707bd0907fdbd69be81554c0f30
|
||||
ARG APKO=cgr.dev/chainguard/apko@sha256:c53d15d53090012cdc9f138b667a73a283b0b5bba1b61963ae84be6e28cfe5a0
|
||||
|
||||
FROM ${APKO} AS apko
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:${SDK} AS build-root
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
COPY --from=apko /usr/bin/apko /usr/local/bin/apko
|
||||
COPY services/project-delivery/k8s/workflows/fsharp-runner/fsharp-runner.apko.yaml services/project-delivery/k8s/workflows/fsharp-runner/fsharp-runner.apko.lock.json /configuration/
|
||||
|
||||
RUN set -eu; \
|
||||
case "$TARGETARCH" in \
|
||||
amd64) arch=x86_64 ;; \
|
||||
arm64) arch=arm64 ;; \
|
||||
*) exit 1 ;; \
|
||||
esac; \
|
||||
mkdir -p /image /rootfs; \
|
||||
apko build --sbom=false --arch "$arch" --lockfile /configuration/fsharp-runner.apko.lock.json /configuration/fsharp-runner.apko.yaml fsharp-runner:root /image/root.tar; \
|
||||
mkdir /image/export; \
|
||||
tar -xf /image/root.tar -C /image/export; \
|
||||
tar --exclude=dev --exclude=./dev -xf /image/export/*.tar.gz -C /rootfs; \
|
||||
rm -rf /rootfs/dev /rootfs/usr/share/bash-completion /rootfs/usr/share/doc /rootfs/usr/share/man
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:${SDK} AS sdk
|
||||
|
||||
COPY packages/fsharp-runner/global.json /source/global.json
|
||||
COPY packages/fsharp-runner/Directory.Build.props /Directory.Build.props
|
||||
COPY packages/fsharp-runner/runner.csproj packages/fsharp-runner/packages.lock.json /restore/
|
||||
COPY packages/fsharp-runner/oci/ /oci/
|
||||
COPY packages/fsharp-runner/hostexec/ /hostexec/
|
||||
|
||||
RUN mkdir /packages && \
|
||||
dotnet restore --locked-mode --packages /packages /restore/runner.csproj && \
|
||||
dotnet build --configuration Release --no-restore --output /managed-out /restore/runner.csproj && \
|
||||
dotnet publish --configuration Release --output /oci-out /oci/Oci.csproj && \
|
||||
dotnet publish --configuration Release --output /host-out /hostexec/HostExec.csproj
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:${RUNTIME} AS runtime
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=build-root /rootfs/ /
|
||||
COPY --from=runtime /usr/share/dotnet/ /usr/share/dotnet/
|
||||
COPY --from=sdk /usr/share/dotnet/sdk/10.0.403/FSharp/ /opt/fsi/
|
||||
COPY --from=sdk /managed-out/AWSSDK.Core.dll /managed-out/AWSSDK.S3.dll /managed-out/SharpCompress.dll /managed-out/ZstdSharp.dll /opt/fsi/packages/
|
||||
COPY --from=sdk /oci-out/Oci.dll /opt/fsi/packages/Oci.dll
|
||||
COPY --from=sdk /host-out/HostExec.dll /host-out/HostExec.deps.json /host-out/HostExec.runtimeconfig.json /opt/fsi/packages/
|
||||
|
||||
ENV DOTNET_MULTILEVEL_LOOKUP=0
|
||||
ENV DOTNET_ROOT=/usr/share/dotnet
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
|
||||
ENV HOME=/tmp
|
||||
|
||||
USER 65532:65532
|
||||
WORKDIR /work
|
||||
|
||||
ENTRYPOINT ["/usr/share/dotnet/dotnet", "/opt/fsi/fsi.dll", "--exec"]
|
||||
2
packages/fsharp-runner/Dockerfile.bootstrap.dockerignore
Normal file
2
packages/fsharp-runner/Dockerfile.bootstrap.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
**/bin
|
||||
**/obj
|
||||
12
packages/fsharp-runner/global.json
Normal file
12
packages/fsharp-runner/global.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"sdk": {
|
||||
"version": "10.0.403",
|
||||
"rollForward": "latestPatch"
|
||||
},
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
},
|
||||
"msbuild-sdks": {
|
||||
"MSTest.Sdk": "4.3.3"
|
||||
}
|
||||
}
|
||||
3
packages/fsharp-runner/hostexec-tests/AssemblyInfo.cs
Normal file
3
packages/fsharp-runner/hostexec-tests/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
using System.Runtime.Versioning;
|
||||
|
||||
[assembly: SupportedOSPlatform("linux")]
|
||||
15
packages/fsharp-runner/hostexec-tests/HostExec.Tests.csproj
Normal file
15
packages/fsharp-runner/hostexec-tests/HostExec.Tests.csproj
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<Project Sdk="MSTest.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>$(NoWarn);CA1515;NETSDK1188</NoWarn>
|
||||
<RunnerSourceRoot Condition="'$(RunnerSourceRoot)' == ''">..</RunnerSourceRoot>
|
||||
<TestingExtensionsProfile>None</TestingExtensionsProfile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="probe/**/*.cs" />
|
||||
<ProjectReference Include="$(RunnerSourceRoot)/hostexec/HostExec.csproj" />
|
||||
<ProjectReference Include="probe/HostExec.Probe.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
110
packages/fsharp-runner/hostexec-tests/Program.cs
Normal file
110
packages/fsharp-runner/hostexec-tests/Program.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Quorate.HostExec;
|
||||
|
||||
namespace Quorate.HostExec.Tests;
|
||||
|
||||
[TestClass]
|
||||
[SupportedOSPlatform("linux")]
|
||||
public sealed class HostExecTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task InstallsAndRunsGnuProxy()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(root, "usr/bin"));
|
||||
string probe = Path.Combine(AppContext.BaseDirectory, "HostExec.Probe");
|
||||
File.CreateSymbolicLink(Path.Combine(root, "usr/bin/gcc"), probe);
|
||||
ToolRoot toolRoot = ToolRoot.Open(root);
|
||||
string proxy = GnuProxy.Install(toolRoot, GnuTool.Gcc);
|
||||
Assert.AreEqual(
|
||||
"#!/usr/share/dotnet/dotnet /opt/fsi/packages/HostExec.dll\n",
|
||||
await File.ReadAllTextAsync(proxy).ConfigureAwait(false));
|
||||
Assert.IsTrue(File.GetUnixFileMode(proxy).HasFlag(UnixFileMode.UserExecute));
|
||||
Assert.IsTrue(File.Exists($"{proxy}.json"));
|
||||
|
||||
IReadOnlyList<string> supplied = Proxy.WithSysroot(root, ["-c", "source.c"]);
|
||||
CollectionAssert.AreEqual(new[] { $"--sysroot={root}", "-c", "source.c" }, supplied.ToArray());
|
||||
|
||||
IReadOnlyList<string> retained = Proxy.WithSysroot(root, [$"--sysroot={root}", "source.c"]);
|
||||
Assert.HasCount(2, retained);
|
||||
Assert.ThrowsExactly<ArgumentException>(() => Proxy.WithSysroot(root, ["--sysroot=/different", "source.c"]));
|
||||
Assert.ThrowsExactly<ArgumentException>(() => Proxy.WithSysroot(root, ["--sysroot"]));
|
||||
Assert.ThrowsExactly<ArgumentException>(() => ToolRoot.Open("relative"));
|
||||
|
||||
string assembly = typeof(ToolRoot).Assembly.Location;
|
||||
string runtime = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
|
||||
string dotnet = Path.Combine(Path.GetFullPath(Path.Combine(runtime, "../../..")), "dotnet");
|
||||
await File.WriteAllTextAsync(proxy, $"#!{dotnet} {assembly}\n").ConfigureAwait(false);
|
||||
var start = new ProcessStartInfo(proxy)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
start.ArgumentList.Add("probe.c");
|
||||
using Process child = Process.Start(start)
|
||||
?? throw new InvalidOperationException("proxy did not start");
|
||||
string output = await child.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
|
||||
await child.WaitForExitAsync().ConfigureAwait(false);
|
||||
Assert.AreEqual(0, child.ExitCode);
|
||||
Assert.AreEqual($"--sysroot={root} probe.c\n", output);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ForwardsTermination()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(root, "usr/bin"));
|
||||
string probe = Path.Combine(AppContext.BaseDirectory, "HostExec.Probe");
|
||||
File.CreateSymbolicLink(Path.Combine(root, "usr/bin/gcc"), probe);
|
||||
string proxy = GnuProxy.Install(ToolRoot.Open(root), GnuTool.Gcc);
|
||||
string assembly = typeof(ToolRoot).Assembly.Location;
|
||||
string runtime = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
|
||||
string dotnet = Path.Combine(Path.GetFullPath(Path.Combine(runtime, "../../..")), "dotnet");
|
||||
await File.WriteAllTextAsync(proxy, $"#!{dotnet} {assembly}\n").ConfigureAwait(false);
|
||||
var start = new ProcessStartInfo(proxy)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
start.ArgumentList.Add("--wait");
|
||||
using Process child = Process.Start(start)
|
||||
?? throw new InvalidOperationException("proxy did not start");
|
||||
string childLine = await child.StandardOutput.ReadLineAsync()
|
||||
.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false)
|
||||
?? throw new InvalidDataException("probe did not report its process identity");
|
||||
int childId = int.Parse(childLine, CultureInfo.InvariantCulture);
|
||||
Assert.AreEqual(0, Kill(child.Id, 15));
|
||||
await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
Assert.AreEqual(143, child.ExitCode);
|
||||
Assert.AreEqual(-1, Kill(childId, 0));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("libc", EntryPoint = "kill", SetLastError = true)]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
|
||||
private static extern int Kill(int processId, int signal);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
9
packages/fsharp-runner/hostexec-tests/probe/Program.cs
Normal file
9
packages/fsharp-runner/hostexec-tests/probe/Program.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
if (Array.IndexOf(args, "--wait") >= 0)
|
||||
{
|
||||
Console.WriteLine(Environment.ProcessId);
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(string.Join(" ", args));
|
||||
}
|
||||
5
packages/fsharp-runner/hostexec/AssemblyInfo.cs
Normal file
5
packages/fsharp-runner/hostexec/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("HostExec.Tests")]
|
||||
[assembly: SupportedOSPlatform("linux")]
|
||||
114
packages/fsharp-runner/hostexec/GnuProxy.cs
Normal file
114
packages/fsharp-runner/hostexec/GnuProxy.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Quorate.HostExec;
|
||||
|
||||
[SuppressMessage("Maintainability", "CA1515", Justification = "Loaded by F# actions.")]
|
||||
public enum GnuTool
|
||||
{
|
||||
C,
|
||||
Cpp,
|
||||
Gcc,
|
||||
Gxx
|
||||
}
|
||||
|
||||
[SuppressMessage("Maintainability", "CA1515", Justification = "Loaded by F# actions.")]
|
||||
public sealed class ToolRoot
|
||||
{
|
||||
private ToolRoot(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public static ToolRoot Open(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
if (!System.IO.Path.IsPathFullyQualified(path))
|
||||
{
|
||||
throw new ArgumentException("tool root must be absolute", nameof(path));
|
||||
}
|
||||
|
||||
var fullPath = System.IO.Path.GetFullPath(path);
|
||||
|
||||
if (!Directory.Exists(fullPath))
|
||||
{
|
||||
throw new DirectoryNotFoundException(fullPath);
|
||||
}
|
||||
|
||||
return new ToolRoot(fullPath);
|
||||
}
|
||||
|
||||
internal string Resolve(string relative)
|
||||
{
|
||||
if (System.IO.Path.IsPathFullyQualified(relative))
|
||||
{
|
||||
throw new ArgumentException("tool path must be relative", nameof(relative));
|
||||
}
|
||||
|
||||
var path = System.IO.Path.GetFullPath(System.IO.Path.Combine(Path, relative));
|
||||
var prefix = Path + System.IO.Path.DirectorySeparatorChar;
|
||||
|
||||
if (!path.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("tool path escapes its root", nameof(relative));
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
[SuppressMessage("Maintainability", "CA1515", Justification = "Loaded by F# actions.")]
|
||||
public static class GnuProxy
|
||||
{
|
||||
private const string Launcher = "#!/usr/share/dotnet/dotnet /opt/fsi/packages/HostExec.dll\n";
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public static string Install(ToolRoot root, GnuTool tool)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
var name = Name(tool);
|
||||
var relative = $"usr/bin/{name}";
|
||||
var executable = root.Resolve(relative);
|
||||
|
||||
if (!File.Exists(executable))
|
||||
{
|
||||
throw new FileNotFoundException("GNU tool is unavailable", executable);
|
||||
}
|
||||
|
||||
var target = root.Resolve($"usr/local/bin/{name}");
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(target)!);
|
||||
File.WriteAllText(target, Launcher);
|
||||
File.SetUnixFileMode(
|
||||
target,
|
||||
UnixFileMode.UserRead
|
||||
| UnixFileMode.UserWrite
|
||||
| UnixFileMode.UserExecute
|
||||
| UnixFileMode.GroupRead
|
||||
| UnixFileMode.GroupExecute
|
||||
| UnixFileMode.OtherRead
|
||||
| UnixFileMode.OtherExecute
|
||||
);
|
||||
|
||||
File.WriteAllText($"{target}.json", JsonSerializer.Serialize(new ProxyFile(1, "gnu", relative), Json));
|
||||
return target;
|
||||
}
|
||||
|
||||
private static string Name(GnuTool tool) =>
|
||||
tool switch
|
||||
{
|
||||
GnuTool.C => "cc",
|
||||
GnuTool.Cpp => "c++",
|
||||
GnuTool.Gcc => "gcc",
|
||||
GnuTool.Gxx => "g++",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tool))
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed record ProxyFile(int Version, string Kind, string File);
|
||||
9
packages/fsharp-runner/hostexec/HostExec.csproj
Normal file
9
packages/fsharp-runner/hostexec/HostExec.csproj
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseAppHost>false</UseAppHost>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
25
packages/fsharp-runner/hostexec/Program.cs
Normal file
25
packages/fsharp-runner/hostexec/Program.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using Quorate.HostExec;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[SuppressMessage("Design", "CA1031", Justification = "The executable boundary maps failures to the proxy exit code.")]
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
await Console.Error.WriteLineAsync("proxy path is missing").ConfigureAwait(false);
|
||||
return 125;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await Proxy.Run(args[0], args[1..]).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
await Console.Error.WriteLineAsync($"{error.GetType().Name}: {error.Message}").ConfigureAwait(false);
|
||||
return 125;
|
||||
}
|
||||
}
|
||||
}
|
||||
119
packages/fsharp-runner/hostexec/Proxy.cs
Normal file
119
packages/fsharp-runner/hostexec/Proxy.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Quorate.HostExec;
|
||||
|
||||
internal static class Proxy
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static async Task<int> Run(string wrapper, IReadOnlyList<string> arguments)
|
||||
{
|
||||
var fullWrapper = Path.GetFullPath(wrapper);
|
||||
var root = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(fullWrapper)!, "../../.."));
|
||||
var config = Read($"{fullWrapper}.json");
|
||||
|
||||
if (config.Version != 1 || config.Kind != "gnu")
|
||||
{
|
||||
throw new InvalidDataException("unsupported proxy configuration");
|
||||
}
|
||||
|
||||
var toolRoot = ToolRoot.Open(root);
|
||||
var executable = toolRoot.Resolve(config.File);
|
||||
|
||||
if (!File.Exists(executable))
|
||||
{
|
||||
throw new FileNotFoundException("proxied tool is unavailable", executable);
|
||||
}
|
||||
|
||||
var start = new ProcessStartInfo(executable)
|
||||
{
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
foreach (var argument in WithSysroot(root, arguments))
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
using var stop = new CancellationTokenSource();
|
||||
var signalCode = 143;
|
||||
using var interrupt = PosixSignalRegistration.Create(PosixSignal.SIGINT, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
signalCode = 130;
|
||||
stop.Cancel();
|
||||
});
|
||||
using var terminate = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
signalCode = 143;
|
||||
stop.Cancel();
|
||||
});
|
||||
using var child = Process.Start(start) ?? throw new InvalidOperationException("proxied tool did not start");
|
||||
|
||||
try
|
||||
{
|
||||
await child.WaitForExitAsync(stop.Token).ConfigureAwait(false);
|
||||
return child.ExitCode;
|
||||
}
|
||||
catch (OperationCanceledException) when (stop.IsCancellationRequested)
|
||||
{
|
||||
if (!child.HasExited)
|
||||
{
|
||||
child.Kill(true);
|
||||
await child.WaitForExitAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return signalCode;
|
||||
}
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> WithSysroot(string root, IReadOnlyList<string> arguments)
|
||||
{
|
||||
var fullRoot = Path.GetFullPath(root);
|
||||
var found = false;
|
||||
|
||||
for (var index = 0; index < arguments.Count; index++)
|
||||
{
|
||||
var argument = arguments[index];
|
||||
string? value = null;
|
||||
|
||||
if (argument == "--sysroot")
|
||||
{
|
||||
if (++index >= arguments.Count)
|
||||
{
|
||||
throw new ArgumentException("sysroot value is missing", nameof(arguments));
|
||||
}
|
||||
|
||||
value = arguments[index];
|
||||
}
|
||||
else if (argument.StartsWith("--sysroot=", StringComparison.Ordinal))
|
||||
{
|
||||
value = argument[10..];
|
||||
}
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
if (!Path.GetFullPath(value).Equals(fullRoot, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("conflicting sysroot", nameof(arguments));
|
||||
}
|
||||
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
return found ? arguments : [$"--sysroot={fullRoot}", .. arguments];
|
||||
}
|
||||
|
||||
private static ProxyFile Read(string path)
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<ProxyFile>(File.ReadAllBytes(path), Json);
|
||||
return value ?? throw new InvalidDataException("proxy configuration is empty");
|
||||
}
|
||||
}
|
||||
14
packages/fsharp-runner/oci-tests/Oci.Tests.csproj
Normal file
14
packages/fsharp-runner/oci-tests/Oci.Tests.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="MSTest.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>$(NoWarn);CA1515;NETSDK1188</NoWarn>
|
||||
<RunnerSourceRoot Condition="'$(RunnerSourceRoot)' == ''">..</RunnerSourceRoot>
|
||||
<TestingExtensionsProfile>None</TestingExtensionsProfile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="$(RunnerSourceRoot)/oci/Oci.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
764
packages/fsharp-runner/oci-tests/Program.cs
Normal file
764
packages/fsharp-runner/oci-tests/Program.cs
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using Quorate.Oci;
|
||||
|
||||
namespace Quorate.Oci.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class RegistryTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task PublishesLayoutWithConcurrentAuthentication()
|
||||
{
|
||||
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||||
using var handler = new RegistryHandler(layout.ManifestDigest);
|
||||
using var registry = new Registry(
|
||||
new Uri("https://registry.example"),
|
||||
new RegistryAuth("writer", "password"),
|
||||
handler);
|
||||
|
||||
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.AreEqual(layout.ManifestDigest, published);
|
||||
Assert.AreEqual(2, handler.TokenRequests);
|
||||
Assert.HasCount(2, handler.Uploaded);
|
||||
Assert.HasCount(2, handler.Manifests);
|
||||
Assert.AreEqual(layout.ManifestDigest, handler.Manifests.Last());
|
||||
Assert.AreEqual(nameof(RegistryAuth), new RegistryAuth("writer", "password").ToString());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RejectsCancelledPublication()
|
||||
{
|
||||
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||||
using var handler = new RegistryHandler(layout.ManifestDigest);
|
||||
using var registry = new Registry(
|
||||
new Uri("https://registry.example"),
|
||||
new RegistryAuth("writer", "password"),
|
||||
handler);
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
await cancelled.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
registry.PushAsync("quorate/test", layout.Path, "test", "test", cancelled.Token))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void KeepsInjectedHandler()
|
||||
{
|
||||
using var handler = new RegistryHandler("sha256:" + new string('0', 64));
|
||||
|
||||
using (var registry = new Registry(
|
||||
new Uri("https://registry.example"),
|
||||
new RegistryAuth("writer", "password"),
|
||||
handler))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.IsFalse(handler.Disposed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task CancelsActiveHash()
|
||||
{
|
||||
var stream = new BlockingStream();
|
||||
var cancelled = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
Task<OciDigest> hashing = OciDigest.ComputeAsync(stream, cancelled.Token);
|
||||
await stream.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
await cancelled.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await hashing.ConfigureAwait(false);
|
||||
Assert.Fail("hashing ignored cancellation");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancelled.Dispose();
|
||||
await stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task CancelsActiveUpload()
|
||||
{
|
||||
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||||
using var handler = new RegistryHandler(layout.ManifestDigest) { BlockUpload = true };
|
||||
using var registry = new Registry(
|
||||
new Uri("https://registry.example"),
|
||||
new RegistryAuth("writer", "password"),
|
||||
handler);
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
Task<string> publication = registry.PushAsync(
|
||||
"quorate/test",
|
||||
layout.Path,
|
||||
"test",
|
||||
"test",
|
||||
cancelled.Token);
|
||||
await handler.UploadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
await cancelled.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => publication).ConfigureAwait(false);
|
||||
Assert.IsTrue(handler.Cancelled > 0);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task AcceptsUploadUris()
|
||||
{
|
||||
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||||
|
||||
foreach (Uri location in new[]
|
||||
{
|
||||
new Uri("/upload/fixed", UriKind.Relative),
|
||||
new Uri("https://registry.example/upload/fixed")
|
||||
})
|
||||
{
|
||||
using var handler = new RegistryHandler(layout.ManifestDigest) { UploadLocation = location };
|
||||
using var registry = new Registry(
|
||||
new Uri("https://registry.example"),
|
||||
new RegistryAuth("writer", "password"),
|
||||
handler);
|
||||
|
||||
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
|
||||
.ConfigureAwait(false);
|
||||
Assert.AreEqual(layout.ManifestDigest, published);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RejectsForeignUpload()
|
||||
{
|
||||
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||||
using var handler = new RegistryHandler(layout.ManifestDigest)
|
||||
{
|
||||
UploadLocation = new Uri("https://other.example/upload/fixed")
|
||||
};
|
||||
using var registry = new Registry(
|
||||
new Uri("https://registry.example"),
|
||||
new RegistryAuth("writer", "password"),
|
||||
handler);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() =>
|
||||
registry.PushAsync("quorate/test", layout.Path, "test", "test")).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RejectsBadChallenges()
|
||||
{
|
||||
Assert.ThrowsExactly<InvalidDataException>(() => BearerChallenge.Parse(
|
||||
new AuthenticationHeaderValue("Bearer", "realm=\"http://auth.example/token\"")));
|
||||
Assert.ThrowsExactly<InvalidDataException>(() => BearerChallenge.Parse(
|
||||
new AuthenticationHeaderValue("Bearer", "realm=https://auth.example/token")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task NegotiatesHttp2WithPrivateRoot()
|
||||
{
|
||||
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||||
LoopbackRegistry server = await LoopbackRegistry.StartAsync(layout.ManifestDigest)
|
||||
.ConfigureAwait(false);
|
||||
await using (server.ConfigureAwait(false))
|
||||
{
|
||||
using var registry = new Registry(
|
||||
server.Endpoint,
|
||||
new RegistryAuth("writer", "password"),
|
||||
server.Root);
|
||||
|
||||
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.AreEqual(layout.ManifestDigest, published);
|
||||
Assert.IsFalse(server.Protocols.IsEmpty);
|
||||
Assert.IsTrue(server.Protocols.All(protocol => protocol == "HTTP/2"));
|
||||
Assert.AreEqual(2, server.TokenRequests);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FallsBackToHttp1()
|
||||
{
|
||||
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||||
LoopbackRegistry server = await LoopbackRegistry.StartAsync(
|
||||
layout.ManifestDigest,
|
||||
HttpProtocols.Http1).ConfigureAwait(false);
|
||||
await using (server.ConfigureAwait(false))
|
||||
{
|
||||
using var registry = new Registry(
|
||||
server.Endpoint,
|
||||
new RegistryAuth("writer", "password"),
|
||||
server.Root);
|
||||
|
||||
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.AreEqual(layout.ManifestDigest, published);
|
||||
Assert.IsFalse(server.Protocols.IsEmpty);
|
||||
Assert.IsTrue(server.Protocols.All(protocol => protocol == "HTTP/1.1"));
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RejectsWrongHost()
|
||||
{
|
||||
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||||
LoopbackRegistry server = await LoopbackRegistry.StartAsync(layout.ManifestDigest)
|
||||
.ConfigureAwait(false);
|
||||
await using (server.ConfigureAwait(false))
|
||||
{
|
||||
var endpoint = new UriBuilder(server.Endpoint) { Host = "localhost" };
|
||||
using var registry = new Registry(
|
||||
endpoint.Uri,
|
||||
new RegistryAuth("writer", "password"),
|
||||
server.Root);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||
registry.PushAsync("quorate/test", layout.Path, "test", "test")).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RejectsRedirects()
|
||||
{
|
||||
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||||
LoopbackRegistry server = await LoopbackRegistry.StartAsync(
|
||||
layout.ManifestDigest,
|
||||
HttpProtocols.Http2,
|
||||
true).ConfigureAwait(false);
|
||||
await using (server.ConfigureAwait(false))
|
||||
{
|
||||
using var registry = new Registry(
|
||||
server.Endpoint,
|
||||
new RegistryAuth("writer", "password"),
|
||||
server.Root);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||
registry.PushAsync("quorate/test", layout.Path, "test", "test")).ConfigureAwait(false);
|
||||
Assert.AreEqual(0, server.RedirectHits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LoopbackRegistry : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication application;
|
||||
private readonly bool redirectUploads;
|
||||
private readonly X509Certificate2 serverCertificate;
|
||||
private readonly string manifestDigest;
|
||||
private int tokenRequests;
|
||||
|
||||
private LoopbackRegistry(
|
||||
WebApplication application,
|
||||
X509Certificate2 root,
|
||||
X509Certificate2 serverCertificate,
|
||||
string manifestDigest,
|
||||
bool redirectUploads)
|
||||
{
|
||||
this.application = application;
|
||||
Root = root;
|
||||
this.serverCertificate = serverCertificate;
|
||||
this.manifestDigest = manifestDigest;
|
||||
this.redirectUploads = redirectUploads;
|
||||
}
|
||||
|
||||
public Uri Endpoint { get; private set; } = null!;
|
||||
public X509Certificate2 Root { get; }
|
||||
public ConcurrentQueue<string> Protocols { get; } = [];
|
||||
public int RedirectHits { get; private set; }
|
||||
public int TokenRequests => Volatile.Read(ref tokenRequests);
|
||||
|
||||
public static async Task<LoopbackRegistry> StartAsync(
|
||||
string manifestDigest,
|
||||
HttpProtocols protocols = HttpProtocols.Http2,
|
||||
bool redirectUploads = false)
|
||||
{
|
||||
(X509Certificate2 root, X509Certificate2 serverCertificate) = CreateCertificates();
|
||||
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.ConfigureKestrel(options => options.Listen(
|
||||
IPAddress.Loopback,
|
||||
0,
|
||||
endpoint =>
|
||||
{
|
||||
endpoint.Protocols = protocols;
|
||||
endpoint.UseHttps(serverCertificate);
|
||||
}));
|
||||
WebApplication application = builder.Build();
|
||||
var registry = new LoopbackRegistry(
|
||||
application,
|
||||
root,
|
||||
serverCertificate,
|
||||
manifestDigest,
|
||||
redirectUploads);
|
||||
application.Run(registry.HandleAsync);
|
||||
await application.StartAsync().ConfigureAwait(false);
|
||||
IServer server = application.Services.GetRequiredService<IServer>();
|
||||
string address = server.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
|
||||
registry.Endpoint = new Uri(address);
|
||||
return registry;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await application.StopAsync().ConfigureAwait(false);
|
||||
await application.DisposeAsync().ConfigureAwait(false);
|
||||
serverCertificate.Dispose();
|
||||
Root.Dispose();
|
||||
}
|
||||
|
||||
private async Task HandleAsync(HttpContext context)
|
||||
{
|
||||
Protocols.Enqueue(context.Request.Protocol);
|
||||
string path = context.Request.Path.Value ?? "";
|
||||
string authorization = context.Request.Headers.Authorization.ToString();
|
||||
|
||||
if (path == "/redirected")
|
||||
{
|
||||
RedirectHits++;
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (path == "/token")
|
||||
{
|
||||
Interlocked.Increment(ref tokenRequests);
|
||||
|
||||
if (!authorization.StartsWith("Basic ", StringComparison.Ordinal))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
string token = context.Request.Query["scope"].ToString().EndsWith(
|
||||
":pull,push",
|
||||
StringComparison.Ordinal) ? "push-token" : "pull-token";
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsync($"{{\"token\":\"{token}\"}}", context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Request.Method == HttpMethods.Head &&
|
||||
path.Contains("/blobs/", StringComparison.Ordinal))
|
||||
{
|
||||
if (!authorization.StartsWith("Bearer ", StringComparison.Ordinal))
|
||||
{
|
||||
Deny(context, "pull");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Request.Method == HttpMethods.Post &&
|
||||
path.EndsWith("/blobs/uploads/", StringComparison.Ordinal))
|
||||
{
|
||||
if (authorization != "Bearer push-token")
|
||||
{
|
||||
Deny(context, "pull,push");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
context.Response.Headers.Append(HeaderNames.Location, "/upload/" + Guid.NewGuid().ToString("N"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Request.Method == HttpMethods.Put &&
|
||||
path.StartsWith("/upload/", StringComparison.Ordinal))
|
||||
{
|
||||
if (redirectUploads)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status307TemporaryRedirect;
|
||||
context.Response.Headers.Location = "/redirected";
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] content = await ReadBodyAsync(context).ConfigureAwait(false);
|
||||
string digest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content));
|
||||
|
||||
if (digest != context.Request.Query["digest"])
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
Created(context, digest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Request.Method == HttpMethods.Delete &&
|
||||
path.StartsWith("/upload/", StringComparison.Ordinal))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Request.Method == HttpMethods.Put &&
|
||||
path.Contains("/manifests/", StringComparison.Ordinal))
|
||||
{
|
||||
byte[] content = await ReadBodyAsync(context).ConfigureAwait(false);
|
||||
Created(context, "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Request.Method == HttpMethods.Head &&
|
||||
path.EndsWith("/manifests/test", StringComparison.Ordinal))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.Headers.Append("Docker-Content-Digest", manifestDigest);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadBodyAsync(HttpContext context)
|
||||
{
|
||||
using var content = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(content, context.RequestAborted).ConfigureAwait(false);
|
||||
return content.ToArray();
|
||||
}
|
||||
|
||||
private void Deny(HttpContext context, string actions)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.Headers.Append(
|
||||
HeaderNames.WWWAuthenticate,
|
||||
$"Bearer realm=\"{Endpoint}token\",service=\"loopback\",scope=\"repository:quorate/test:{actions}\"");
|
||||
}
|
||||
|
||||
private static void Created(HttpContext context, string digest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status201Created;
|
||||
context.Response.Headers.Append("Docker-Content-Digest", digest);
|
||||
}
|
||||
|
||||
private static (X509Certificate2 Root, X509Certificate2 Server) CreateCertificates()
|
||||
{
|
||||
DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
DateTimeOffset notAfter = notBefore.AddHours(1);
|
||||
using RSA rootKey = RSA.Create(2048);
|
||||
var rootRequest = new CertificateRequest(
|
||||
"CN=Quorate OCI Test Root",
|
||||
rootKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
||||
true));
|
||||
rootRequest.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(rootRequest.PublicKey, false));
|
||||
using X509Certificate2 rootWithKey = rootRequest.CreateSelfSigned(notBefore, notAfter);
|
||||
X509Certificate2 root = X509CertificateLoader.LoadCertificate(
|
||||
rootWithKey.Export(X509ContentType.Cert));
|
||||
using RSA serverKey = RSA.Create(2048);
|
||||
var serverRequest = new CertificateRequest(
|
||||
"CN=127.0.0.1",
|
||||
serverKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
serverRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
serverRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
|
||||
true));
|
||||
var usages = new OidCollection { new("1.3.6.1.5.5.7.3.1") };
|
||||
serverRequest.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(usages, true));
|
||||
var names = new SubjectAlternativeNameBuilder();
|
||||
names.AddIpAddress(IPAddress.Loopback);
|
||||
serverRequest.CertificateExtensions.Add(names.Build());
|
||||
byte[] serial = RandomNumberGenerator.GetBytes(16);
|
||||
using X509Certificate2 publicServer = serverRequest.Create(
|
||||
rootWithKey,
|
||||
notBefore,
|
||||
notAfter,
|
||||
serial);
|
||||
X509Certificate2 server = publicServer.CopyWithPrivateKey(serverKey);
|
||||
return (root, server);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LayoutFixture : IDisposable
|
||||
{
|
||||
private LayoutFixture(string path, string manifestDigest)
|
||||
{
|
||||
Path = path;
|
||||
ManifestDigest = manifestDigest;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
public string ManifestDigest { get; }
|
||||
|
||||
public static async Task<LayoutFixture> CreateAsync()
|
||||
{
|
||||
string root = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"oci-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(System.IO.Path.Combine(root, "blobs", "sha256"));
|
||||
byte[] config = Encoding.UTF8.GetBytes("{}");
|
||||
byte[] layer = Encoding.UTF8.GetBytes("layer");
|
||||
string configDigest = AddBlob(config);
|
||||
string layerDigest = AddBlob(layer);
|
||||
byte[] manifest = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
schemaVersion = 2,
|
||||
mediaType = "application/vnd.oci.image.manifest.v1+json",
|
||||
config = new
|
||||
{
|
||||
mediaType = "application/vnd.oci.image.config.v1+json",
|
||||
digest = configDigest,
|
||||
size = config.Length
|
||||
},
|
||||
layers = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
mediaType = "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
digest = layerDigest,
|
||||
size = layer.Length
|
||||
}
|
||||
}
|
||||
});
|
||||
string manifestDigest = AddBlob(manifest);
|
||||
byte[] index = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
schemaVersion = 2,
|
||||
manifests = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
mediaType = "application/vnd.oci.image.manifest.v1+json",
|
||||
digest = manifestDigest,
|
||||
size = manifest.Length,
|
||||
annotations = new Dictionary<string, string>
|
||||
{
|
||||
["org.opencontainers.image.ref.name"] = "test"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
await File.WriteAllBytesAsync(System.IO.Path.Combine(root, "index.json"), index)
|
||||
.ConfigureAwait(false);
|
||||
return new LayoutFixture(root, manifestDigest);
|
||||
|
||||
string AddBlob(byte[] content)
|
||||
{
|
||||
string digest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content));
|
||||
File.WriteAllBytes(System.IO.Path.Combine(root, "blobs", "sha256", digest[7..]), content);
|
||||
return digest;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(Path, true);
|
||||
}
|
||||
|
||||
internal sealed class RegistryHandler(string manifestDigest) : HttpMessageHandler
|
||||
{
|
||||
private int cancelled;
|
||||
private bool disposed;
|
||||
private int tokenRequests;
|
||||
|
||||
public bool BlockUpload { get; init; }
|
||||
public int Cancelled => Volatile.Read(ref cancelled);
|
||||
public bool Disposed => Volatile.Read(ref disposed);
|
||||
public int TokenRequests => Volatile.Read(ref tokenRequests);
|
||||
public TaskCompletionSource<bool> UploadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Uri? UploadLocation { get; init; }
|
||||
public ConcurrentBag<string> Uploaded { get; } = [];
|
||||
public ConcurrentQueue<string> Manifests { get; } = [];
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
Volatile.Write(ref disposed, disposing);
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Assert.AreEqual(HttpVersion.Version20, request.Version);
|
||||
Assert.AreEqual(HttpVersionPolicy.RequestVersionOrLower, request.VersionPolicy);
|
||||
Uri uri = request.RequestUri!;
|
||||
|
||||
if (uri.Host == "auth.example")
|
||||
{
|
||||
Interlocked.Increment(ref tokenRequests);
|
||||
Assert.AreEqual("Basic", request.Headers.Authorization!.Scheme);
|
||||
string token = Query(uri, "scope").EndsWith(":pull,push", StringComparison.Ordinal)
|
||||
? "push-token"
|
||||
: "pull-token";
|
||||
return Reply(HttpStatusCode.OK, request, $"{{\"token\":\"{token}\"}}");
|
||||
}
|
||||
|
||||
string path = uri.AbsolutePath;
|
||||
|
||||
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal) &&
|
||||
request.Headers.Authorization?.Scheme != "Bearer")
|
||||
{
|
||||
return Denied(request, "pull");
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Post && path.EndsWith("/blobs/uploads/", StringComparison.Ordinal) &&
|
||||
request.Headers.Authorization?.Parameter != "push-token")
|
||||
{
|
||||
return Denied(request, "pull,push");
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
|
||||
{
|
||||
return Reply(HttpStatusCode.NotFound, request);
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Post && path.EndsWith("/blobs/uploads/", StringComparison.Ordinal))
|
||||
{
|
||||
HttpResponseMessage started = Reply(HttpStatusCode.Accepted, request);
|
||||
started.Headers.Location = UploadLocation
|
||||
?? new Uri("/upload/" + Guid.NewGuid().ToString("N"), UriKind.Relative);
|
||||
return started;
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Put && path.StartsWith("/upload/", StringComparison.Ordinal))
|
||||
{
|
||||
UploadStarted.TrySetResult(true);
|
||||
|
||||
if (BlockUpload)
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
string digest = Query(uri, "digest");
|
||||
byte[] content = await request.Content!.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
Assert.AreEqual(digest, "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content)));
|
||||
Uploaded.Add(digest);
|
||||
HttpResponseMessage completed = Reply(HttpStatusCode.Created, request);
|
||||
completed.Headers.Add("Docker-Content-Digest", digest);
|
||||
return completed;
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Delete && path.StartsWith("/upload/", StringComparison.Ordinal))
|
||||
{
|
||||
Interlocked.Increment(ref cancelled);
|
||||
return Reply(HttpStatusCode.Accepted, request);
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
|
||||
{
|
||||
byte[] content = await request.Content!.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
string digest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content));
|
||||
Manifests.Enqueue(digest);
|
||||
HttpResponseMessage completed = Reply(HttpStatusCode.Created, request);
|
||||
completed.Headers.Add("Docker-Content-Digest", digest);
|
||||
return completed;
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/test", StringComparison.Ordinal))
|
||||
{
|
||||
HttpResponseMessage completed = Reply(HttpStatusCode.OK, request);
|
||||
completed.Headers.Add("Docker-Content-Digest", manifestDigest);
|
||||
return completed;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected request {request.Method} {uri}");
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Reply(
|
||||
HttpStatusCode status,
|
||||
HttpRequestMessage request,
|
||||
string? content = null) => new(status)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent(content ?? "")
|
||||
};
|
||||
|
||||
private static HttpResponseMessage Denied(HttpRequestMessage request, string actions)
|
||||
{
|
||||
HttpResponseMessage denied = Reply(HttpStatusCode.Unauthorized, request);
|
||||
denied.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
$"realm=\"https://auth.example/token\",service=\"registry.example\",scope=\"repository:quorate/test:{actions}\""));
|
||||
return denied;
|
||||
}
|
||||
|
||||
private static string Query(Uri uri, string name)
|
||||
{
|
||||
foreach (string pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
string[] fields = pair.Split('=', 2);
|
||||
|
||||
if (Uri.UnescapeDataString(fields[0]) == name)
|
||||
{
|
||||
return Uri.UnescapeDataString(fields[1]);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Missing query parameter {name}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BlockingStream : Stream
|
||||
{
|
||||
public TaskCompletionSource<bool> Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
public override async Task<int> ReadAsync(
|
||||
byte[] buffer,
|
||||
int offset,
|
||||
int count,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Started.TrySetResult(true);
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(
|
||||
Memory<byte> buffer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Started.TrySetResult(true);
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
3
packages/fsharp-runner/oci/AssemblyInfo.cs
Normal file
3
packages/fsharp-runner/oci/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Oci.Tests")]
|
||||
7
packages/fsharp-runner/oci/Oci.csproj
Normal file
7
packages/fsharp-runner/oci/Oci.csproj
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
209
packages/fsharp-runner/oci/OciLayout.cs
Normal file
209
packages/fsharp-runner/oci/OciLayout.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace Quorate.Oci;
|
||||
|
||||
internal sealed record OciBlob(OciDigest Digest, long Size, string Path);
|
||||
|
||||
internal sealed record OciManifest(OciDigest Digest, string MediaType, byte[] Content);
|
||||
|
||||
internal sealed record OciPublicationPlan(
|
||||
OciDigest RootDigest,
|
||||
string RootMediaType,
|
||||
byte[] RootContent,
|
||||
IReadOnlyList<OciBlob> Blobs,
|
||||
IReadOnlyList<OciManifest> Manifests);
|
||||
|
||||
internal static class OciLayout
|
||||
{
|
||||
private const string RefName = "org.opencontainers.image.ref.name";
|
||||
|
||||
public static async Task<OciPublicationPlan> LoadAsync(
|
||||
string layout,
|
||||
OciTag sourceTag,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(layout);
|
||||
string root = Path.GetFullPath(layout);
|
||||
string blobRoot = Path.Combine(root, "blobs", "sha256");
|
||||
byte[] indexContent = await File.ReadAllBytesAsync(
|
||||
Path.Combine(root, "index.json"),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
OciDocumentModel index = Deserialize(indexContent, "OCI index");
|
||||
OciDescriptorModel rootDescriptor = SelectRoot(index, sourceTag);
|
||||
var blobs = new Dictionary<OciDigest, OciBlob>();
|
||||
var manifests = new List<OciManifest>();
|
||||
var complete = new HashSet<OciDigest>();
|
||||
var active = new HashSet<OciDigest>();
|
||||
|
||||
await VisitAsync(
|
||||
blobRoot,
|
||||
rootDescriptor,
|
||||
blobs,
|
||||
manifests,
|
||||
complete,
|
||||
active,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
OciManifest rootManifest = manifests[^1];
|
||||
return new OciPublicationPlan(
|
||||
rootManifest.Digest,
|
||||
rootManifest.MediaType,
|
||||
rootManifest.Content,
|
||||
blobs.Values.OrderBy(item => item.Digest.ToString(), StringComparer.Ordinal).ToArray(),
|
||||
manifests);
|
||||
}
|
||||
|
||||
private static async Task VisitAsync(
|
||||
string blobRoot,
|
||||
OciDescriptorModel descriptor,
|
||||
Dictionary<OciDigest, OciBlob> blobs,
|
||||
List<OciManifest> manifests,
|
||||
HashSet<OciDigest> complete,
|
||||
HashSet<OciDigest> active,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
OciDigest digest = OciDigest.Parse(descriptor.Digest);
|
||||
|
||||
if (complete.Contains(digest))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active.Add(digest))
|
||||
{
|
||||
throw new InvalidDataException($"OCI manifest graph contains a cycle at {digest}.");
|
||||
}
|
||||
|
||||
string mediaType = RequireManifestType(descriptor.MediaType);
|
||||
string path = BlobPath(blobRoot, digest);
|
||||
byte[] content = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
ValidateSize(descriptor.Size, content.LongLength, digest);
|
||||
OciDigest actual = OciDigest.Compute(content);
|
||||
|
||||
if (actual != digest)
|
||||
{
|
||||
throw new InvalidDataException($"Manifest digest {actual} does not match {digest}.");
|
||||
}
|
||||
|
||||
OciDocumentModel document = Deserialize(content, $"OCI manifest {digest}");
|
||||
|
||||
if (IsIndex(mediaType))
|
||||
{
|
||||
OciDescriptorModel[] children = document.Manifests
|
||||
?? throw new InvalidDataException($"OCI index {digest} has no manifests.");
|
||||
|
||||
foreach (OciDescriptorModel child in children)
|
||||
{
|
||||
await VisitAsync(
|
||||
blobRoot,
|
||||
child,
|
||||
blobs,
|
||||
manifests,
|
||||
complete,
|
||||
active,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddBlob(blobRoot, document.Config, blobs, digest, "config");
|
||||
OciDescriptorModel[] layers = document.Layers
|
||||
?? throw new InvalidDataException($"OCI manifest {digest} has no layers.");
|
||||
|
||||
foreach (OciDescriptorModel layer in layers)
|
||||
{
|
||||
AddBlob(blobRoot, layer, blobs, digest, "layer");
|
||||
}
|
||||
}
|
||||
|
||||
active.Remove(digest);
|
||||
complete.Add(digest);
|
||||
manifests.Add(new OciManifest(digest, mediaType, content));
|
||||
}
|
||||
|
||||
private static void AddBlob(
|
||||
string blobRoot,
|
||||
OciDescriptorModel? descriptor,
|
||||
Dictionary<OciDigest, OciBlob> blobs,
|
||||
OciDigest manifest,
|
||||
string kind)
|
||||
{
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new InvalidDataException($"OCI manifest {manifest} has no {kind} descriptor.");
|
||||
}
|
||||
|
||||
OciDigest digest = OciDigest.Parse(descriptor.Digest);
|
||||
var blob = new OciBlob(digest, descriptor.Size, BlobPath(blobRoot, digest));
|
||||
|
||||
if (blobs.TryGetValue(digest, out OciBlob? existing) && existing.Size != blob.Size)
|
||||
{
|
||||
throw new InvalidDataException($"OCI blob {digest} has conflicting sizes.");
|
||||
}
|
||||
|
||||
blobs.TryAdd(digest, blob);
|
||||
}
|
||||
|
||||
private static OciDescriptorModel SelectRoot(OciDocumentModel index, OciTag tag)
|
||||
{
|
||||
OciDescriptorModel[] manifests = index.Manifests
|
||||
?? throw new InvalidDataException("OCI index has no manifests.");
|
||||
OciDescriptorModel[] named = manifests.Where(item =>
|
||||
item.Annotations?.TryGetValue(RefName, out string? name) == true &&
|
||||
StringComparer.Ordinal.Equals(name, tag.ToString())).ToArray();
|
||||
|
||||
return named.Length switch
|
||||
{
|
||||
1 => named[0],
|
||||
0 when manifests.Length == 1 => manifests[0],
|
||||
_ => throw new InvalidDataException($"OCI layout does not contain one root for {tag}.")
|
||||
};
|
||||
}
|
||||
|
||||
private static OciDocumentModel Deserialize(byte[] content, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
OciDocumentModel value = JsonSerializer.Deserialize(
|
||||
content,
|
||||
OciJsonContext.Default.OciDocumentModel)
|
||||
?? throw new InvalidDataException($"{name} is empty.");
|
||||
|
||||
if (value.SchemaVersion != 2)
|
||||
{
|
||||
throw new InvalidDataException($"{name} has unsupported schema version {value.SchemaVersion}.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
catch (JsonException error)
|
||||
{
|
||||
throw new InvalidDataException($"{name} is invalid.", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static string RequireManifestType(string? mediaType) =>
|
||||
mediaType switch
|
||||
{
|
||||
"application/vnd.docker.distribution.manifest.v2+json" => mediaType,
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json" => mediaType,
|
||||
"application/vnd.oci.image.manifest.v1+json" => mediaType,
|
||||
"application/vnd.oci.image.index.v1+json" => mediaType,
|
||||
null => throw new InvalidDataException("Manifest media type is unavailable."),
|
||||
_ => throw new InvalidDataException($"Unsupported manifest media type {mediaType}.")
|
||||
};
|
||||
|
||||
private static bool IsIndex(string mediaType) =>
|
||||
mediaType is "application/vnd.docker.distribution.manifest.list.v2+json" or
|
||||
"application/vnd.oci.image.index.v1+json";
|
||||
|
||||
private static void ValidateSize(long expected, long actual, OciDigest digest)
|
||||
{
|
||||
if (expected < 0 || expected != actual)
|
||||
{
|
||||
throw new InvalidDataException($"OCI content {digest} has size {actual}, expected {expected}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BlobPath(string root, OciDigest digest) => Path.Combine(root, digest.Hex);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue