mbproxy: cross-platform support — Linux/systemd alongside Windows
Make the service build, run, and install on Linux as a first-class target while keeping the Windows Service + Event Log behaviour intact. - Build: drop the hardcoded win-x64 RID — single-file publish now works for any RID. publish.ps1 gains -Rid; new publish.sh for Linux hosts. - Diagnostics: DiagnosticSinkSelector picks the Error+ sink per host — Windows Event Log under the SCM, local syslog under systemd (Serilog.Sinks.SyslogMessages), none for interactive runs. The EventLog truncation helper is extracted so it is testable cross-OS. - Host: Program.cs registers AddSystemd() alongside AddWindowsService(). - Config: a RID-conditioned appsettings template ships Windows or Unix paths; both templates are schema-validated by a test. - Install: systemd unit (Type=exec) plus install.sh / uninstall.sh. Also fixes two cross-platform bugs found while testing: install.ps1 and uninstall.ps1 used New-EventLog / Remove-EventLog (absent in PowerShell 7), and the E2E sim launcher hardcoded Windows venv paths. - Docs updated across README, CLAUDE.md, and docs/ for dual-platform. 413 tests pass on Windows; 374 (all non-simulator) on Linux. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
using Mbproxy.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DiagnosticSinkSelector"/> — the pure platform-selection
|
||||
/// logic for the Error+ diagnostic sink. Covers every OS / host combination so the
|
||||
/// selection contract is pinned without needing a real Windows Service or systemd host.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DiagnosticSinkSelectorTests
|
||||
{
|
||||
// 'expected' is the underlying int of DiagnosticSink: the enum is internal and
|
||||
// cannot appear in a public (xunit-discoverable) method signature.
|
||||
[Theory]
|
||||
[InlineData(true, true, false, (int)DiagnosticSink.EventLog)] // Windows, hosted as a Windows Service
|
||||
[InlineData(true, false, false, (int)DiagnosticSink.None)] // Windows, interactive / dev run
|
||||
[InlineData(false, false, true, (int)DiagnosticSink.Syslog)] // Linux, hosted as a systemd service
|
||||
[InlineData(false, false, false, (int)DiagnosticSink.None)] // Linux / macOS, interactive / dev run
|
||||
public void Select_PicksExpectedSink(
|
||||
bool isWindows, bool isWindowsService, bool isSystemd, int expected)
|
||||
=> ((int)DiagnosticSinkSelector.Select(isWindows, isWindowsService, isSystemd)).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Select_Windows_TakesPrecedence_OverASpuriousSystemdFlag()
|
||||
=> DiagnosticSinkSelector.Select(isWindows: true, isWindowsService: true, isSystemd: true)
|
||||
.ShouldBe(DiagnosticSink.EventLog);
|
||||
|
||||
[Fact]
|
||||
public void Select_WindowsConsoleRun_GetsNoSink_EvenIfSystemdFlagSet()
|
||||
=> DiagnosticSinkSelector.Select(isWindows: true, isWindowsService: false, isSystemd: true)
|
||||
.ShouldBe(DiagnosticSink.None);
|
||||
|
||||
[Fact]
|
||||
public void Select_NonWindowsWithoutSystemd_GetsNoSink()
|
||||
=> DiagnosticSinkSelector.Select(isWindows: false, isWindowsService: false, isSystemd: false)
|
||||
.ShouldBe(DiagnosticSink.None);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Mbproxy.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EventLogMessage.TruncateToLimit"/> — the 32 KB Windows
|
||||
/// Event Log truncation rule. The helper is pure and OS-agnostic, so these run on
|
||||
/// every platform (the Windows-only <see cref="EventLogBridge"/> sink itself is not
|
||||
/// exercised here).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EventLogMessageTests
|
||||
{
|
||||
[Fact]
|
||||
public void TruncateToLimit_ShortMessage_ReturnedUnchanged()
|
||||
{
|
||||
const string msg = "mbproxy backend connect failed";
|
||||
EventLogMessage.TruncateToLimit(msg).ShouldBeSameAs(msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruncateToLimit_MessageAtTheLimit_NotTruncated()
|
||||
{
|
||||
// MaxBytes / 2 chars = exactly MaxBytes at the 2-bytes-per-char upper bound.
|
||||
var atLimit = new string('y', EventLogMessage.MaxBytes / 2);
|
||||
EventLogMessage.TruncateToLimit(atLimit).ShouldBe(atLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruncateToLimit_OversizeMessage_TruncatedWithinLimit_AndEndsWithEllipsis()
|
||||
{
|
||||
var huge = new string('x', EventLogMessage.MaxBytes); // well over the limit
|
||||
var result = EventLogMessage.TruncateToLimit(huge);
|
||||
|
||||
(result.Length * 2).ShouldBeLessThanOrEqualTo(EventLogMessage.MaxBytes);
|
||||
result.ShouldEndWith("...");
|
||||
result.Length.ShouldBeLessThan(huge.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Mbproxy.Diagnostics;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SyslogBridge"/>. The bridge's fail-safe contract is that
|
||||
/// attaching the local-syslog sink and building the resulting logger never throw —
|
||||
/// even on a host with no <c>/dev/log</c> (e.g. the Windows test leg), where the sink
|
||||
/// connects lazily and degrades silently.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SyslogBridgeTests
|
||||
{
|
||||
[Fact]
|
||||
public void AttachTo_ReturnsAConfiguration_AndNeverThrows()
|
||||
=> SyslogBridge.AttachTo(new LoggerConfiguration()).ShouldNotBeNull();
|
||||
|
||||
[Fact]
|
||||
public void AttachTo_ResultCreatesALogger_WithoutThrowing()
|
||||
{
|
||||
using var logger = SyslogBridge.AttachTo(new LoggerConfiguration()).CreateLogger();
|
||||
logger.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Hosting.Systemd;
|
||||
using Microsoft.Extensions.Hosting.WindowsServices;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
@@ -71,6 +73,26 @@ public sealed class HostSmokeTests
|
||||
// Assert: does not throw / time out.
|
||||
await stopTask.ShouldCompleteWithinAsync(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostSmoke_BothInitSystemIntegrations_CoRegister_AndHostRunsCleanly()
|
||||
{
|
||||
// Arrange: register BOTH init-system integrations. Each is a no-op off its
|
||||
// own init system, so on a test run (neither) the default console lifetime
|
||||
// applies — they must co-register without conflict and leave the host
|
||||
// startable and stoppable.
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Services.AddWindowsService();
|
||||
builder.Services.AddSystemd();
|
||||
builder.ConfigureForTest(new LoggerConfiguration().CreateLogger());
|
||||
|
||||
using var host = builder.Build();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Act + Assert: start/stop do not throw or time out.
|
||||
await host.StartAsync(cts.Token);
|
||||
await host.StopAsync(cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -102,6 +102,39 @@ public sealed class MbproxyOptionsBindingTests
|
||||
options.Resilience.ListenerRecovery.InitialBackoffMs.ShouldBe([1000, 2000, 5000, 15000, 30000]);
|
||||
options.Plcs.ShouldBeEmpty();
|
||||
options.BcdTags.Global.ShouldBeEmpty();
|
||||
|
||||
// Keepalive defaults — enabled, with the documented timer values.
|
||||
options.Connection.Keepalive.Enabled.ShouldBeTrue();
|
||||
options.Connection.Keepalive.TcpIdleTimeMs.ShouldBe(30000);
|
||||
options.Connection.Keepalive.TcpProbeIntervalMs.ShouldBe(5000);
|
||||
options.Connection.Keepalive.TcpProbeCount.ShouldBe(4);
|
||||
options.Connection.Keepalive.BackendHeartbeatIdleMs.ShouldBe(30000);
|
||||
options.Connection.Keepalive.BackendHeartbeatProbeAddress.ShouldBe(0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 5 — the Connection:Keepalive block binds from configuration
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void MbproxyOptionsBinding_BindsKeepaliveBlock()
|
||||
{
|
||||
var options = BindOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:Connection:Keepalive:Enabled"] = "false",
|
||||
["Mbproxy:Connection:Keepalive:TcpIdleTimeMs"] = "45000",
|
||||
["Mbproxy:Connection:Keepalive:TcpProbeIntervalMs"] = "7000",
|
||||
["Mbproxy:Connection:Keepalive:TcpProbeCount"] = "6",
|
||||
["Mbproxy:Connection:Keepalive:BackendHeartbeatIdleMs"] = "20000",
|
||||
["Mbproxy:Connection:Keepalive:BackendHeartbeatProbeAddress"] = "1024",
|
||||
});
|
||||
|
||||
var ka = options.Connection.Keepalive;
|
||||
ka.Enabled.ShouldBeFalse();
|
||||
ka.TcpIdleTimeMs.ShouldBe(45000);
|
||||
ka.TcpProbeIntervalMs.ShouldBe(7000);
|
||||
ka.TcpProbeCount.ShouldBe(6);
|
||||
ka.BackendHeartbeatIdleMs.ShouldBe(20000);
|
||||
ka.BackendHeartbeatProbeAddress.ShouldBe(1024);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -129,4 +162,47 @@ public sealed class MbproxyOptionsBindingTests
|
||||
result.Failed.ShouldBeTrue("Width=8 should fail schema validation");
|
||||
result.Failures.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 6 — every shipped install template (Windows + Linux) loads as JSONC,
|
||||
// binds to MbproxyOptions, and passes schema validation. This catches
|
||||
// a malformed template at build time and keeps the two platform
|
||||
// variants in lockstep.
|
||||
// -------------------------------------------------------------------------
|
||||
[Theory]
|
||||
[InlineData("mbproxy.config.template.json")]
|
||||
[InlineData("mbproxy.linux.config.template.json")]
|
||||
public void MbproxyOptionsBinding_ShippedInstallTemplate_BindsAndValidates(string templateFileName)
|
||||
{
|
||||
var templatePath = ResolveInstallFile(templateFileName);
|
||||
|
||||
// The templates are JSONC; the .NET JSON config provider skips // and /* */
|
||||
// comments and allows trailing commas, so AddJsonFile loads them directly.
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile(templatePath, optional: false)
|
||||
.Build();
|
||||
|
||||
var options = config.GetSection("Mbproxy").Get<MbproxyOptions>() ?? new MbproxyOptions();
|
||||
|
||||
var result = new MbproxyOptionsValidator().Validate(null, options);
|
||||
result.Succeeded.ShouldBeTrue(
|
||||
$"{templateFileName} must pass schema validation — failures: " +
|
||||
string.Join("; ", result.Failures ?? []));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <c>install/</c> file by walking up from the test assembly directory.
|
||||
/// Works from both the Windows dev box and the Linux test box.
|
||||
/// </summary>
|
||||
private static string ResolveInstallFile(string fileName)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "install", fileName);
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
}
|
||||
throw new FileNotFoundException(
|
||||
$"Could not locate install/{fileName} above {AppContext.BaseDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user