diff --git a/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/DiagLog.cs b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/DiagLog.cs new file mode 100644 index 00000000..f2b3a9de --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/DiagLog.cs @@ -0,0 +1,39 @@ +using System.Globalization; + +namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; + +/// +/// Best-effort diagnostics: writes UTC-timestamped lines to stderr and, when a log path is configured, +/// appends them to a file (relative to the exe). Never throws — diagnostics must not break the YES/NO path. +/// +internal sealed class DiagLog(string? logPath) +{ + private readonly string? _logPath = string.IsNullOrWhiteSpace(logPath) ? null : logPath; + + public void Write(string message) + { + var line = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} {message}"); + Console.Error.WriteLine(line); + + if (_logPath is null) + { + return; + } + + try + { + var fullPath = Path.IsPathRooted(_logPath) ? _logPath : Path.Combine(AppContext.BaseDirectory, _logPath); + var dir = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + File.AppendAllText(fullPath, line + Environment.NewLine); + } + catch + { + // diagnostics are best-effort; swallow file errors so the YES/NO contract is unaffected + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs index 7d6959e6..28a458eb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs @@ -2,5 +2,72 @@ namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; internal static class Program { - public static int Main(string[] args) => 0; // replaced in Task 7 + public static async Task Main(string[] args) + { + var stdout = Console.Out; + + // Config first, so the diagnostic log can honour the configured LogPath even for early failures. + NotifierConfig cfg; + try + { + cfg = ConfigLoader.LoadFromDefaultFile(); + } + catch (Exception ex) + { + new DiagLog(null).Write("config load failed: " + ex.Message); + return Reporter.Report(false, "config load failed: " + ex.Message, stdout); + } + + var log = new DiagLog(cfg.ScadaBridge.LogPath); + + try + { + var parse = ArgParser.Parse(args); + if (!parse.Ok) + { + log.Write("arg error: " + parse.Error); + return Reporter.Report(false, parse.Error!, stdout); + } + + var baseUrls = ConfigLoader.SplitBaseUrls(cfg.ScadaBridge.BaseUrls); + if (baseUrls.Length == 0) + { + log.Write("no BaseUrls configured"); + return Reporter.Report(false, "no BaseUrls configured", stdout); + } + + var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable); + if (apiKey is null) + { + log.Write("API key not configured (SCADABRIDGE_API_KEY)"); + return Reporter.Report(false, "API key not configured (SCADABRIDGE_API_KEY)", stdout); + } + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(cfg.ScadaBridge.TimeoutSeconds) }; + var sender = new LoggingRecipeSender(new HttpRecipeSender(http, apiKey), log); + + log.Write($"notifying {baseUrls.Length} URL(s) for machine {parse.Payload!.MachineCode}"); + var result = await Notifier.RunAsync(baseUrls, parse.Payload, sender, CancellationToken.None); + log.Write($"result: ok={result.Ok} reason={result.Reason}"); + return Reporter.Report(result.Ok, result.Reason, stdout); + } + catch (Exception ex) + { + log.Write("unexpected error: " + ex); + return Reporter.Report(false, "unexpected error: " + ex.Message, stdout); + } + } + + /// Decorates a sender to emit a per-attempt diagnostic line; keeps stdout reserved for the YES/NO contract. + private sealed class LoggingRecipeSender(IRecipeSender inner, DiagLog log) : IRecipeSender + { + public async Task SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct) + { + var outcome = await inner.SendAsync(baseUrl, payload, ct); + log.Write(outcome.Kind == AttemptKind.ConnectFailed + ? $"attempt {baseUrl}: connect failed: {outcome.Error}" + : $"attempt {baseUrl}: HTTP {outcome.StatusCode}"); + return outcome; + } + } } diff --git a/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Reporter.cs b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Reporter.cs new file mode 100644 index 00000000..e8419785 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Reporter.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; + +/// +/// Maps an outcome to the legacy WWNotifier stdout contract: YES + exit 0 on success, +/// NO followed by a reason line + exit -1 on failure. The writer is injected so stdout is the +/// only thing Delmia ever parses (LF-terminated, deterministic across platforms). +/// +internal static class Reporter +{ + public static int Report(bool ok, string reason, TextWriter stdout) + { + if (ok) + { + stdout.Write("YES\n"); + return 0; + } + + stdout.Write("NO\n"); + stdout.Write(reason); + return -1; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs new file mode 100644 index 00000000..88c65ce7 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs @@ -0,0 +1,24 @@ +using ZB.MOM.WW.ScadaBridge.DelmiaNotifier; + +namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests; + +public class ReporterTests +{ + [Fact] + public void Success_writes_YES_and_returns_0() + { + using var sw = new StringWriter(); + var code = Reporter.Report(true, "", sw); + Assert.Equal("YES\n", sw.ToString()); + Assert.Equal(0, code); + } + + [Fact] + public void Failure_writes_NO_then_reason_and_returns_minus1() + { + using var sw = new StringWriter(); + var code = Reporter.Report(false, "boom", sw); + Assert.Equal("NO\nboom", sw.ToString()); + Assert.Equal(-1, code); + } +}