feat(server): adds minimum keepalive and headroom, chore: updates packages to fix vulnerabilities
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
using Ngino.Server;
|
||||
using Xunit;
|
||||
|
||||
namespace ReverseLlama.Client.Tests;
|
||||
|
||||
public class GroupKeepalivePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void KeepalivePolicy_IsRoundTripped_ThroughGroupClientInfo()
|
||||
{
|
||||
var policy = new GroupClientKeepalivePolicy(2, 3, 4);
|
||||
var info = new GroupClientInfo(1, "group-1", "client-1", "model-a", "pattern", policy);
|
||||
|
||||
Assert.Equal(2, info.KeepalivePolicy?.InstancesToKeepAlive);
|
||||
Assert.Equal(3, info.KeepalivePolicy?.MaxParallelismPerClient);
|
||||
Assert.Equal(4, info.KeepalivePolicy?.ParallelismHeadroom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Ngino.Client\Ngino.Client.csproj" />
|
||||
<ProjectReference Include="..\..\src\Ngino.Server\Ngino.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Ngino.Client.Tests;
|
||||
|
||||
public sealed class PackageAuditTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Solution_HasNoKnownVulnerablePackages()
|
||||
{
|
||||
var solutionPath = FindSolutionPath();
|
||||
var result = await RunDotnetPackageAuditAsync(solutionPath);
|
||||
|
||||
Assert.True(
|
||||
result.ExitCode == 0,
|
||||
$"Package audit command failed with exit code {result.ExitCode}.{Environment.NewLine}{result.Output}{result.Error}");
|
||||
|
||||
using var document = JsonDocument.Parse(result.Output);
|
||||
var findings = new List<string>();
|
||||
CollectVulnerablePackages(document.RootElement, findings);
|
||||
|
||||
Assert.True(
|
||||
findings.Count == 0,
|
||||
"Known vulnerable packages were found:"
|
||||
+ Environment.NewLine
|
||||
+ string.Join(Environment.NewLine, findings));
|
||||
}
|
||||
|
||||
private static string FindSolutionPath()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var solutionPath = Path.Combine(directory.FullName, "Ngino.sln");
|
||||
if (File.Exists(solutionPath))
|
||||
{
|
||||
return solutionPath;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not find Ngino.sln from the test output directory.");
|
||||
}
|
||||
|
||||
private static async Task<CommandResult> RunDotnetPackageAuditAsync(string solutionPath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
WorkingDirectory = Path.GetDirectoryName(solutionPath)!,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("list");
|
||||
startInfo.ArgumentList.Add(solutionPath);
|
||||
startInfo.ArgumentList.Add("package");
|
||||
startInfo.ArgumentList.Add("--vulnerable");
|
||||
startInfo.ArgumentList.Add("--include-transitive");
|
||||
startInfo.ArgumentList.Add("--format");
|
||||
startInfo.ArgumentList.Add("json");
|
||||
startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
|
||||
|
||||
using var process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Could not start dotnet package audit.");
|
||||
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync();
|
||||
var errorTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort; the assertion below will fail with the timeout message.
|
||||
}
|
||||
|
||||
throw new TimeoutException("dotnet package audit did not finish within 2 minutes.");
|
||||
}
|
||||
|
||||
return new CommandResult(
|
||||
process.ExitCode,
|
||||
await outputTask,
|
||||
await errorTask);
|
||||
}
|
||||
|
||||
private static void CollectVulnerablePackages(JsonElement element, List<string> findings)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (element.TryGetProperty("vulnerabilities", out var vulnerabilities)
|
||||
&& vulnerabilities.ValueKind == JsonValueKind.Array
|
||||
&& vulnerabilities.GetArrayLength() > 0)
|
||||
{
|
||||
findings.Add(DescribePackageFinding(element, vulnerabilities));
|
||||
}
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
CollectVulnerablePackages(property.Value, findings);
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
CollectVulnerablePackages(item, findings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribePackageFinding(JsonElement package, JsonElement vulnerabilities)
|
||||
{
|
||||
var id = package.TryGetProperty("id", out var idElement)
|
||||
? idElement.GetString()
|
||||
: "<unknown package>";
|
||||
var version = package.TryGetProperty("resolvedVersion", out var versionElement)
|
||||
? versionElement.GetString()
|
||||
: "<unknown version>";
|
||||
|
||||
var advisories = vulnerabilities
|
||||
.EnumerateArray()
|
||||
.Select(vulnerability => DescribeVulnerability(vulnerability))
|
||||
.ToArray();
|
||||
|
||||
return $"- {id} {version}: {string.Join(", ", advisories)}";
|
||||
}
|
||||
|
||||
private static string DescribeVulnerability(JsonElement vulnerability)
|
||||
{
|
||||
var severity = vulnerability.TryGetProperty("severity", out var severityElement)
|
||||
? severityElement.GetString()
|
||||
: "unknown severity";
|
||||
var advisoryUrl = vulnerability.TryGetProperty("advisoryUrl", out var advisoryElement)
|
||||
? advisoryElement.GetString()
|
||||
: "unknown advisory";
|
||||
|
||||
return $"{severity} {advisoryUrl}";
|
||||
}
|
||||
|
||||
private sealed record CommandResult(int ExitCode, string Output, string Error);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json;
|
||||
using Ngino.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace Ngino.Client.Tests;
|
||||
|
||||
public sealed class TunnelClientModelTests
|
||||
{
|
||||
private static readonly Uri Upstream = new("http://localhost:11434");
|
||||
|
||||
[Fact]
|
||||
public void ExtractModelNames_ReadsActiveOllamaPsModelNames()
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"models": [
|
||||
{ "name": "qwen3.5:0.8b", "model": "qwen3.5:0.8b" },
|
||||
{ "name": "llama3.2:latest", "model": "llama3.2:latest" },
|
||||
{ "name": " QWEN3.5:0.8B " }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var models = TunnelClient.ExtractModelNames(document.RootElement);
|
||||
|
||||
Assert.Equal(["llama3.2:latest", "qwen3.5:0.8b"], models);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildModelCommandRequest_LoadUsesOllamaPreloadRequest()
|
||||
{
|
||||
using var request = TunnelClient.BuildModelCommandRequest(Upstream, "load", " qwen3.5:0.8b ");
|
||||
var body = await request.Content!.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(body);
|
||||
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal("http://localhost:11434/api/generate", request.RequestUri!.AbsoluteUri);
|
||||
Assert.Equal("qwen3.5:0.8b", document.RootElement.GetProperty("model").GetString());
|
||||
Assert.False(document.RootElement.TryGetProperty("prompt", out _));
|
||||
Assert.False(document.RootElement.GetProperty("stream").GetBoolean());
|
||||
Assert.Equal(-1, document.RootElement.GetProperty("keep_alive").GetInt32());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("load", -1)]
|
||||
[InlineData("unload", 0)]
|
||||
public async Task BuildEmbeddingModelCommandRequest_UsesOllamaEmbedWarmupRequest(
|
||||
string command,
|
||||
int expectedKeepAlive)
|
||||
{
|
||||
using var request = TunnelClient.BuildEmbeddingModelCommandRequest(Upstream, command, " bge-m3:latest ");
|
||||
var body = await request.Content!.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(body);
|
||||
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal("http://localhost:11434/api/embed", request.RequestUri!.AbsoluteUri);
|
||||
Assert.Equal("bge-m3:latest", document.RootElement.GetProperty("model").GetString());
|
||||
Assert.Equal("Ngino warmup", document.RootElement.GetProperty("input").GetString());
|
||||
Assert.Equal(expectedKeepAlive, document.RootElement.GetProperty("keep_alive").GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Ngino.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace Ngino.Client.Tests;
|
||||
|
||||
public sealed class UpstreamRequestTests
|
||||
{
|
||||
private static readonly Uri Upstream = new("http://localhost:11434");
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/tags", "http://localhost:11434/api/tags")]
|
||||
[InlineData("/api/tags?model=llama3.1", "http://localhost:11434/api/tags?model=llama3.1")]
|
||||
[InlineData("/api//tags", "http://localhost:11434/api//tags")]
|
||||
public void BuildUpstreamUri_AcceptsOriginFormPaths(string pathAndQuery, string expected)
|
||||
{
|
||||
var uri = UpstreamRequest.BuildUpstreamUri(Upstream, pathAndQuery);
|
||||
|
||||
Assert.Equal(expected, uri.AbsoluteUri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("//169.254.169.254/latest")]
|
||||
[InlineData("http://169.254.169.254/latest")]
|
||||
[InlineData("https://localhost:11434/api/tags")]
|
||||
[InlineData(@"\\169.254.169.254\latest")]
|
||||
[InlineData(@"/\169.254.169.254/latest")]
|
||||
[InlineData("api/tags")]
|
||||
public void BuildUpstreamUri_RejectsPathsThatCanEscapeTheUpstreamOrigin(string pathAndQuery)
|
||||
{
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => UpstreamRequest.BuildUpstreamUri(Upstream, pathAndQuery));
|
||||
|
||||
Assert.Contains("origin-form path", exception.Message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user