From 0af1427859613221fed9b925fd4ab317cecdf8a9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 16:53:06 -0400 Subject: [PATCH] Issue #21: implement worker bootstrap and options --- docs/mxaccess-worker-instance-design.md | 15 +++ .../Bootstrap/MemoryWorkerEnvironment.cs | 37 ++++++ .../Bootstrap/MemoryWorkerLogEntry.cs | 22 ++++ .../Bootstrap/MemoryWorkerLogger.cs | 19 +++ .../Bootstrap/WorkerApplicationTests.cs | 113 +++++++++++++++++ .../Bootstrap/WorkerConsoleLoggerTests.cs | 28 +++++ .../Bootstrap/WorkerLogRedactorTests.cs | 32 +++++ .../Bootstrap/WorkerOptionsParserTests.cs | 115 ++++++++++++++++++ src/MxGateway.Worker/Bootstrap/.gitkeep | 1 - .../EnvironmentVariableWorkerEnvironment.cs | 11 ++ .../Bootstrap/IWorkerEnvironment.cs | 6 + .../Bootstrap/IWorkerLogger.cs | 10 ++ .../Bootstrap/WorkerBootstrapResult.cs | 35 ++++++ .../Bootstrap/WorkerConsoleLogger.cs | 44 +++++++ .../Bootstrap/WorkerExitCode.cs | 10 ++ .../Bootstrap/WorkerLogRedactor.cs | 50 ++++++++ .../Bootstrap/WorkerOptions.cs | 26 ++++ .../Bootstrap/WorkerOptionsParser.cs | 101 +++++++++++++++ src/MxGateway.Worker/WorkerApplication.cs | 63 +++++++++- 19 files changed, 736 insertions(+), 2 deletions(-) create mode 100644 src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs create mode 100644 src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs create mode 100644 src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs create mode 100644 src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs create mode 100644 src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs create mode 100644 src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs create mode 100644 src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs delete mode 100644 src/MxGateway.Worker/Bootstrap/.gitkeep create mode 100644 src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs create mode 100644 src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs create mode 100644 src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs create mode 100644 src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs create mode 100644 src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs create mode 100644 src/MxGateway.Worker/Bootstrap/WorkerExitCode.cs create mode 100644 src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs create mode 100644 src/MxGateway.Worker/Bootstrap/WorkerOptions.cs create mode 100644 src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs diff --git a/docs/mxaccess-worker-instance-design.md b/docs/mxaccess-worker-instance-design.md index 8bfd9eb..0682494 100644 --- a/docs/mxaccess-worker-instance-design.md +++ b/docs/mxaccess-worker-instance-design.md @@ -114,6 +114,21 @@ Startup sequence: If validation fails before MXAccess creation, exit quickly with a non-zero exit code. If MXAccess creation fails, send `WorkerFault` when possible and exit. +The bootstrap layer returns structured exit codes before it creates pipes, +starts the STA, or touches MXAccess: + +| Exit code | Name | Meaning | +|-----------|------|---------| +| `0` | `Success` | Required bootstrap options are valid. | +| `1` | `UnexpectedFailure` | A non-bootstrap exception reaches the process boundary. | +| `2` | `InvalidArguments` | Required arguments are missing or unknown arguments are present. | +| `3` | `InvalidProtocolVersion` | `--protocol-version` is not numeric or does not match the supported worker protocol. | +| `4` | `MissingNonce` | `MXGATEWAY_WORKER_NONCE` is absent or empty. | + +Bootstrap logs use `WorkerConsoleLogger` key/value output. `WorkerLogRedactor` +redacts fields whose names indicate nonce, secret, password, token, +credential, or API key values before the message is written. + ## Internal Components ```text diff --git a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs new file mode 100644 index 0000000..9b8cb10 --- /dev/null +++ b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using MxGateway.Worker.Bootstrap; + +namespace MxGateway.Worker.Tests.Bootstrap; + +internal sealed class MemoryWorkerEnvironment : IWorkerEnvironment +{ + private readonly Dictionary _values = new(); + private readonly Exception? _exception; + + public MemoryWorkerEnvironment() + { + } + + public MemoryWorkerEnvironment(Exception exception) + { + _exception = exception; + } + + public void Set(string name, string value) + { + _values[name] = value; + } + + public string? GetEnvironmentVariable(string name) + { + if (_exception is not null) + { + throw _exception; + } + + return _values.TryGetValue(name, out string value) + ? value + : null; + } +} diff --git a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs new file mode 100644 index 0000000..8f9c974 --- /dev/null +++ b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace MxGateway.Worker.Tests.Bootstrap; + +internal sealed class MemoryWorkerLogEntry +{ + public MemoryWorkerLogEntry( + string level, + string eventName, + IReadOnlyDictionary fields) + { + Level = level; + EventName = eventName; + Fields = fields; + } + + public string Level { get; } + + public string EventName { get; } + + public IReadOnlyDictionary Fields { get; } +} diff --git a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs new file mode 100644 index 0000000..0b2e312 --- /dev/null +++ b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using MxGateway.Worker.Bootstrap; + +namespace MxGateway.Worker.Tests.Bootstrap; + +internal sealed class MemoryWorkerLogger : IWorkerLogger +{ + public List Entries { get; } = new(); + + public void Information(string eventName, IReadOnlyDictionary fields) + { + Entries.Add(new MemoryWorkerLogEntry("Information", eventName, WorkerLogRedactor.RedactFields(fields))); + } + + public void Error(string eventName, IReadOnlyDictionary fields) + { + Entries.Add(new MemoryWorkerLogEntry("Error", eventName, WorkerLogRedactor.RedactFields(fields))); + } +} diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs b/src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs new file mode 100644 index 0000000..f16aeba --- /dev/null +++ b/src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs @@ -0,0 +1,113 @@ +using System; +using MxGateway.Contracts; +using MxGateway.Worker.Bootstrap; + +namespace MxGateway.Worker.Tests.Bootstrap; + +public sealed class WorkerApplicationTests +{ + [Fact] + public void Run_WithValidBootstrapArguments_ReturnsSuccessAndLogsRedactedNonce() + { + MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret"); + MemoryWorkerLogger logger = new(); + + int exitCode = MxGateway.Worker.WorkerApplication.Run( + ValidArgs(), + environment, + logger); + + Assert.Equal((int)WorkerExitCode.Success, exitCode); + MemoryWorkerLogEntry entry = Assert.Single(logger.Entries); + Assert.Equal("Information", entry.Level); + Assert.Equal("WorkerBootstrapSucceeded", entry.EventName); + Assert.Equal("session-1", entry.Fields["session_id"]); + Assert.Equal("mxaccess-gateway-123-session-1", entry.Fields["pipe_name"]); + Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, entry.Fields["protocol_version"]); + Assert.Equal("[redacted]", entry.Fields["nonce"]); + } + + [Fact] + public void Run_WithMissingRequiredArguments_ReturnsInvalidArguments() + { + MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret"); + MemoryWorkerLogger logger = new(); + + int exitCode = MxGateway.Worker.WorkerApplication.Run( + [], + environment, + logger); + + Assert.Equal((int)WorkerExitCode.InvalidArguments, exitCode); + MemoryWorkerLogEntry entry = Assert.Single(logger.Entries); + Assert.Equal("Error", entry.Level); + Assert.Equal("WorkerBootstrapFailed", entry.EventName); + Assert.Equal(WorkerExitCode.InvalidArguments, entry.Fields["exit_code"]); + } + + [Fact] + public void Run_WithInvalidProtocolVersion_ReturnsInvalidProtocolVersion() + { + MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret"); + MemoryWorkerLogger logger = new(); + + int exitCode = MxGateway.Worker.WorkerApplication.Run( + ValidArgs(protocolVersion: "999"), + environment, + logger); + + Assert.Equal((int)WorkerExitCode.InvalidProtocolVersion, exitCode); + } + + [Fact] + public void Run_WithMissingNonce_ReturnsMissingNonce() + { + MemoryWorkerEnvironment environment = new(); + MemoryWorkerLogger logger = new(); + + int exitCode = MxGateway.Worker.WorkerApplication.Run( + ValidArgs(), + environment, + logger); + + Assert.Equal((int)WorkerExitCode.MissingNonce, exitCode); + } + + [Fact] + public void Run_WithUnexpectedBootstrapFailure_ReturnsUnexpectedFailure() + { + MemoryWorkerEnvironment environment = new(new InvalidOperationException("environment failed")); + MemoryWorkerLogger logger = new(); + + int exitCode = MxGateway.Worker.WorkerApplication.Run( + ValidArgs(), + environment, + logger); + + Assert.Equal((int)WorkerExitCode.UnexpectedFailure, exitCode); + MemoryWorkerLogEntry entry = Assert.Single(logger.Entries); + Assert.Equal("WorkerBootstrapUnexpectedFailure", entry.EventName); + Assert.Equal(WorkerExitCode.UnexpectedFailure, entry.Fields["exit_code"]); + Assert.Equal(typeof(InvalidOperationException).FullName, entry.Fields["exception_type"]); + } + + private static string[] ValidArgs(string? protocolVersion = null) + { + return + [ + "--session-id", + "session-1", + "--pipe-name", + "mxaccess-gateway-123-session-1", + "--protocol-version", + protocolVersion ?? GatewayContractInfo.WorkerProtocolVersion.ToString(), + ]; + } + + private static MemoryWorkerEnvironment CreateEnvironment(string nonce) + { + MemoryWorkerEnvironment environment = new(); + environment.Set(WorkerOptions.NonceEnvironmentVariableName, nonce); + return environment; + } +} diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs b/src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs new file mode 100644 index 0000000..a74877b --- /dev/null +++ b/src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.IO; +using MxGateway.Worker.Bootstrap; + +namespace MxGateway.Worker.Tests.Bootstrap; + +public sealed class WorkerConsoleLoggerTests +{ + [Fact] + public void Information_RedactsNonceInStructuredOutput() + { + StringWriter writer = new(); + WorkerConsoleLogger logger = new(writer); + + logger.Information("WorkerBootstrapSucceeded", new Dictionary + { + ["session_id"] = "session-1", + ["nonce"] = "nonce-secret", + }); + + string output = writer.ToString(); + + Assert.Contains("event=WorkerBootstrapSucceeded", output); + Assert.Contains("session_id=session-1", output); + Assert.Contains("nonce=[redacted]", output); + Assert.DoesNotContain("nonce-secret", output); + } +} diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs b/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs new file mode 100644 index 0000000..d304cbe --- /dev/null +++ b/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using MxGateway.Worker.Bootstrap; + +namespace MxGateway.Worker.Tests.Bootstrap; + +public sealed class WorkerLogRedactorTests +{ + [Fact] + public void RedactFields_RedactsNonceSecretPasswordTokenCredentialAndApiKeyFields() + { + Dictionary fields = new() + { + ["nonce"] = "nonce-secret", + ["client_secret"] = "secret", + ["password"] = "password", + ["auth_token"] = "token", + ["credential_value"] = "credential", + ["api_key"] = "key", + ["session_id"] = "session-1", + }; + + Dictionary redacted = WorkerLogRedactor.RedactFields(fields); + + Assert.Equal("[redacted]", redacted["nonce"]); + Assert.Equal("[redacted]", redacted["client_secret"]); + Assert.Equal("[redacted]", redacted["password"]); + Assert.Equal("[redacted]", redacted["auth_token"]); + Assert.Equal("[redacted]", redacted["credential_value"]); + Assert.Equal("[redacted]", redacted["api_key"]); + Assert.Equal("session-1", redacted["session_id"]); + } +} diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs b/src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs new file mode 100644 index 0000000..4fa1749 --- /dev/null +++ b/src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs @@ -0,0 +1,115 @@ +using MxGateway.Contracts; +using MxGateway.Worker.Bootstrap; + +namespace MxGateway.Worker.Tests.Bootstrap; + +public sealed class WorkerOptionsParserTests +{ + [Fact] + public void Parse_WithAllRequiredInputs_ReturnsWorkerOptions() + { + WorkerOptionsParser parser = new(CreateEnvironment("nonce-secret")); + + WorkerBootstrapResult result = parser.Parse(ValidArgs()); + + Assert.True(result.Succeeded); + Assert.Equal(WorkerExitCode.Success, result.ExitCode); + Assert.NotNull(result.Options); + Assert.Equal("session-1", result.Options.SessionId); + Assert.Equal("mxaccess-gateway-123-session-1", result.Options.PipeName); + Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, result.Options.ProtocolVersion); + Assert.Equal("nonce-secret", result.Options.Nonce); + } + + [Fact] + public void Parse_WithMissingSessionId_ReturnsInvalidArguments() + { + WorkerOptionsParser parser = new(CreateEnvironment("nonce-secret")); + + WorkerBootstrapResult result = parser.Parse( + [ + "--pipe-name", + "mxaccess-gateway-123-session-1", + "--protocol-version", + GatewayContractInfo.WorkerProtocolVersion.ToString(), + ]); + + Assert.False(result.Succeeded); + Assert.Equal(WorkerExitCode.InvalidArguments, result.ExitCode); + Assert.Contains(result.Errors, error => error.Contains("--session-id")); + } + + [Fact] + public void Parse_WithUnknownOption_ReturnsInvalidArguments() + { + WorkerOptionsParser parser = new(CreateEnvironment("nonce-secret")); + + WorkerBootstrapResult result = parser.Parse( + [ + "--session-id", + "session-1", + "--pipe-name", + "mxaccess-gateway-123-session-1", + "--protocol-version", + GatewayContractInfo.WorkerProtocolVersion.ToString(), + "--unexpected", + "value", + ]); + + Assert.Equal(WorkerExitCode.InvalidArguments, result.ExitCode); + Assert.Contains(result.Errors, error => error.Contains("Unknown option")); + } + + [Fact] + public void Parse_WithNonNumericProtocolVersion_ReturnsInvalidProtocolVersion() + { + WorkerOptionsParser parser = new(CreateEnvironment("nonce-secret")); + + WorkerBootstrapResult result = parser.Parse(ValidArgs(protocolVersion: "abc")); + + Assert.False(result.Succeeded); + Assert.Equal(WorkerExitCode.InvalidProtocolVersion, result.ExitCode); + } + + [Fact] + public void Parse_WithUnsupportedProtocolVersion_ReturnsInvalidProtocolVersion() + { + WorkerOptionsParser parser = new(CreateEnvironment("nonce-secret")); + + WorkerBootstrapResult result = parser.Parse(ValidArgs(protocolVersion: "999")); + + Assert.False(result.Succeeded); + Assert.Equal(WorkerExitCode.InvalidProtocolVersion, result.ExitCode); + } + + [Fact] + public void Parse_WithMissingNonce_ReturnsMissingNonce() + { + WorkerOptionsParser parser = new(new MemoryWorkerEnvironment()); + + WorkerBootstrapResult result = parser.Parse(ValidArgs()); + + Assert.False(result.Succeeded); + Assert.Equal(WorkerExitCode.MissingNonce, result.ExitCode); + } + + private static string[] ValidArgs(string? protocolVersion = null) + { + return + [ + "--session-id", + "session-1", + "--pipe-name", + "mxaccess-gateway-123-session-1", + "--protocol-version", + protocolVersion ?? GatewayContractInfo.WorkerProtocolVersion.ToString(), + ]; + } + + private static MemoryWorkerEnvironment CreateEnvironment(string nonce) + { + MemoryWorkerEnvironment environment = new(); + environment.Set(WorkerOptions.NonceEnvironmentVariableName, nonce); + return environment; + } +} diff --git a/src/MxGateway.Worker/Bootstrap/.gitkeep b/src/MxGateway.Worker/Bootstrap/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/src/MxGateway.Worker/Bootstrap/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs b/src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs new file mode 100644 index 0000000..7f6aec6 --- /dev/null +++ b/src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs @@ -0,0 +1,11 @@ +using System; + +namespace MxGateway.Worker.Bootstrap; + +public sealed class EnvironmentVariableWorkerEnvironment : IWorkerEnvironment +{ + public string? GetEnvironmentVariable(string name) + { + return Environment.GetEnvironmentVariable(name); + } +} diff --git a/src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs b/src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs new file mode 100644 index 0000000..3030b62 --- /dev/null +++ b/src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs @@ -0,0 +1,6 @@ +namespace MxGateway.Worker.Bootstrap; + +public interface IWorkerEnvironment +{ + string? GetEnvironmentVariable(string name); +} diff --git a/src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs b/src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs new file mode 100644 index 0000000..e9583a4 --- /dev/null +++ b/src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace MxGateway.Worker.Bootstrap; + +public interface IWorkerLogger +{ + void Information(string eventName, IReadOnlyDictionary fields); + + void Error(string eventName, IReadOnlyDictionary fields); +} diff --git a/src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs b/src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs new file mode 100644 index 0000000..0e751bc --- /dev/null +++ b/src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Linq; + +namespace MxGateway.Worker.Bootstrap; + +public sealed class WorkerBootstrapResult +{ + private WorkerBootstrapResult( + WorkerExitCode exitCode, + WorkerOptions? options, + IReadOnlyList errors) + { + ExitCode = exitCode; + Options = options; + Errors = errors; + } + + public WorkerExitCode ExitCode { get; } + + public WorkerOptions? Options { get; } + + public IReadOnlyList Errors { get; } + + public bool Succeeded => ExitCode == WorkerExitCode.Success; + + public static WorkerBootstrapResult Success(WorkerOptions options) + { + return new WorkerBootstrapResult(WorkerExitCode.Success, options, []); + } + + public static WorkerBootstrapResult Failure(WorkerExitCode exitCode, IEnumerable errors) + { + return new WorkerBootstrapResult(exitCode, null, errors.ToArray()); + } +} diff --git a/src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs b/src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs new file mode 100644 index 0000000..fefe9e8 --- /dev/null +++ b/src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace MxGateway.Worker.Bootstrap; + +public sealed class WorkerConsoleLogger : IWorkerLogger +{ + private readonly TextWriter _writer; + + public WorkerConsoleLogger(TextWriter writer) + { + _writer = writer ?? throw new ArgumentNullException(nameof(writer)); + } + + public void Information(string eventName, IReadOnlyDictionary fields) + { + Write("Information", eventName, fields); + } + + public void Error(string eventName, IReadOnlyDictionary fields) + { + Write("Error", eventName, fields); + } + + private void Write( + string level, + string eventName, + IReadOnlyDictionary fields) + { + Dictionary redactedFields = WorkerLogRedactor.RedactFields(fields); + string fieldText = string.Join( + " ", + redactedFields.Select(field => $"{field.Key}={FormatValue(field.Value)}")); + + _writer.WriteLine($"level={level} event={eventName} {fieldText}".TrimEnd()); + } + + private static string FormatValue(object? value) + { + return value?.ToString() ?? string.Empty; + } +} diff --git a/src/MxGateway.Worker/Bootstrap/WorkerExitCode.cs b/src/MxGateway.Worker/Bootstrap/WorkerExitCode.cs new file mode 100644 index 0000000..5379abd --- /dev/null +++ b/src/MxGateway.Worker/Bootstrap/WorkerExitCode.cs @@ -0,0 +1,10 @@ +namespace MxGateway.Worker.Bootstrap; + +public enum WorkerExitCode +{ + Success = 0, + UnexpectedFailure = 1, + InvalidArguments = 2, + InvalidProtocolVersion = 3, + MissingNonce = 4, +} diff --git a/src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs b/src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs new file mode 100644 index 0000000..860576c --- /dev/null +++ b/src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; + +namespace MxGateway.Worker.Bootstrap; + +public static class WorkerLogRedactor +{ + public const string RedactedValue = "[redacted]"; + + private static readonly string[] SensitiveFieldNameParts = + [ + "nonce", + "secret", + "password", + "token", + "credential", + "apikey", + "api_key", + ]; + + public static Dictionary RedactFields(IReadOnlyDictionary fields) + { + Dictionary redactedFields = []; + + foreach (KeyValuePair field in fields) + { + redactedFields[field.Key] = RedactValue(field.Key, field.Value); + } + + return redactedFields; + } + + public static object? RedactValue(string fieldName, object? value) + { + if (value is null) + { + return null; + } + + foreach (string sensitiveFieldNamePart in SensitiveFieldNameParts) + { + if (fieldName.IndexOf(sensitiveFieldNamePart, StringComparison.OrdinalIgnoreCase) >= 0) + { + return RedactedValue; + } + } + + return value; + } +} diff --git a/src/MxGateway.Worker/Bootstrap/WorkerOptions.cs b/src/MxGateway.Worker/Bootstrap/WorkerOptions.cs new file mode 100644 index 0000000..4317b5d --- /dev/null +++ b/src/MxGateway.Worker/Bootstrap/WorkerOptions.cs @@ -0,0 +1,26 @@ +namespace MxGateway.Worker.Bootstrap; + +public sealed class WorkerOptions +{ + public const string NonceEnvironmentVariableName = "MXGATEWAY_WORKER_NONCE"; + + public WorkerOptions( + string sessionId, + string pipeName, + uint protocolVersion, + string nonce) + { + SessionId = sessionId; + PipeName = pipeName; + ProtocolVersion = protocolVersion; + Nonce = nonce; + } + + public string SessionId { get; } + + public string PipeName { get; } + + public uint ProtocolVersion { get; } + + public string Nonce { get; } +} diff --git a/src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs b/src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs new file mode 100644 index 0000000..fdd236b --- /dev/null +++ b/src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using MxGateway.Contracts; + +namespace MxGateway.Worker.Bootstrap; + +public sealed class WorkerOptionsParser +{ + private const string SessionIdOptionName = "--session-id"; + private const string PipeNameOptionName = "--pipe-name"; + private const string ProtocolVersionOptionName = "--protocol-version"; + + private readonly IWorkerEnvironment _environment; + + public WorkerOptionsParser(IWorkerEnvironment environment) + { + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + } + + public WorkerBootstrapResult Parse(string[] args) + { + if (args is null) + { + throw new ArgumentNullException(nameof(args)); + } + + Dictionary values = new(StringComparer.OrdinalIgnoreCase); + List errors = []; + + for (int index = 0; index < args.Length; index++) + { + string arg = args[index]; + if (!IsKnownOption(arg)) + { + errors.Add($"Unknown option '{arg}'."); + continue; + } + + if (index + 1 >= args.Length || args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + errors.Add($"Option '{arg}' requires a value."); + continue; + } + + values[arg] = args[index + 1]; + index++; + } + + string? sessionId = ReadRequired(values, SessionIdOptionName, errors); + string? pipeName = ReadRequired(values, PipeNameOptionName, errors); + string? protocolVersionText = ReadRequired(values, ProtocolVersionOptionName, errors); + + if (errors.Count > 0) + { + return WorkerBootstrapResult.Failure(WorkerExitCode.InvalidArguments, errors); + } + + if (!uint.TryParse(protocolVersionText, out uint protocolVersion) + || protocolVersion != GatewayContractInfo.WorkerProtocolVersion) + { + return WorkerBootstrapResult.Failure( + WorkerExitCode.InvalidProtocolVersion, + [$"Unsupported protocol version '{protocolVersionText}'."]); + } + + string? nonce = _environment.GetEnvironmentVariable(WorkerOptions.NonceEnvironmentVariableName); + + if (string.IsNullOrWhiteSpace(nonce)) + { + return WorkerBootstrapResult.Failure( + WorkerExitCode.MissingNonce, + ["Required worker nonce environment variable is missing."]); + } + + return WorkerBootstrapResult.Success(new WorkerOptions( + sessionId!, + pipeName!, + protocolVersion, + nonce!)); + } + + private static string? ReadRequired( + IReadOnlyDictionary values, + string optionName, + List errors) + { + if (!values.TryGetValue(optionName, out string value) + || string.IsNullOrWhiteSpace(value)) + { + errors.Add($"Required option '{optionName}' is missing."); + return null; + } + + return value; + } + + private static bool IsKnownOption(string optionName) + { + return optionName is SessionIdOptionName or PipeNameOptionName or ProtocolVersionOptionName; + } +} diff --git a/src/MxGateway.Worker/WorkerApplication.cs b/src/MxGateway.Worker/WorkerApplication.cs index ff1a9f2..1c39a91 100644 --- a/src/MxGateway.Worker/WorkerApplication.cs +++ b/src/MxGateway.Worker/WorkerApplication.cs @@ -1,16 +1,77 @@ using System; +using System.Collections.Generic; +using MxGateway.Worker.Bootstrap; namespace MxGateway.Worker; public static class WorkerApplication { public static int Run(string[] args) + { + return Run( + args, + new EnvironmentVariableWorkerEnvironment(), + new WorkerConsoleLogger(Console.Error)); + } + + public static int Run( + string[] args, + IWorkerEnvironment environment, + IWorkerLogger logger) { if (args is null) { throw new ArgumentNullException(nameof(args)); } - return 0; + if (environment is null) + { + throw new ArgumentNullException(nameof(environment)); + } + + if (logger is null) + { + throw new ArgumentNullException(nameof(logger)); + } + + try + { + WorkerOptionsParser parser = new(environment); + WorkerBootstrapResult result = parser.Parse(args); + + if (!result.Succeeded) + { + logger.Error("WorkerBootstrapFailed", new Dictionary + { + ["exit_code"] = result.ExitCode, + ["errors"] = string.Join(";", result.Errors), + }); + + return (int)result.ExitCode; + } + + WorkerOptions options = result.Options + ?? throw new InvalidOperationException("Successful bootstrap result did not include worker options."); + + logger.Information("WorkerBootstrapSucceeded", new Dictionary + { + ["session_id"] = options.SessionId, + ["pipe_name"] = options.PipeName, + ["protocol_version"] = options.ProtocolVersion, + ["nonce"] = options.Nonce, + }); + + return (int)WorkerExitCode.Success; + } + catch (Exception exception) + { + logger.Error("WorkerBootstrapUnexpectedFailure", new Dictionary + { + ["exit_code"] = WorkerExitCode.UnexpectedFailure, + ["exception_type"] = exception.GetType().FullName, + }); + + return (int)WorkerExitCode.UnexpectedFailure; + } } }