diff --git a/docs/plans/2026-06-26-delmia-recipe-notifier-design.md b/docs/plans/2026-06-26-delmia-recipe-notifier-design.md new file mode 100644 index 00000000..5f99cd6b --- /dev/null +++ b/docs/plans/2026-06-26-delmia-recipe-notifier-design.md @@ -0,0 +1,158 @@ +# Design — Delmia Recipe-Download Notifier (`ZB.MOM.WW.ScadaBridge.DelmiaNotifier`) + +**Date:** 2026-06-26 · **Status:** Approved (brainstorming) · **Next:** implementation plan (writing-plans) + +## Purpose + +A compact Windows console application that **DELMIA Apriso shells out to** on each recipe/NC-program +download to notify the plant system that a recipe was placed at a path for a machine. It is the +modern replacement for the legacy `WWNotifier` (see +[`../former-api-specs/dnc/Delmia-Integration-API.md`](../former-api-specs/dnc/Delmia-Integration-API.md), +Surface B), repointed from the old Wonderware `/notify` receiver to the **ScadaBridge Inbound API** +method `DelmiaRecipeDownload`. + +It is a strict **drop-in** for Delmia's existing call site: same command-line flags, same `YES`/`NO` +stdout contract, same exit-code semantics — so Delmia's invocation and output parsing are unchanged. + +## Key decisions (from brainstorming) + +| Decision | Choice | +|---|---| +| Target | ScadaBridge Inbound API `POST {baseUrl}/api/DelmiaRecipeDownload` | +| Auth | `X-API-Key: ` header | +| CLI/output | Exact parity with legacy `WWNotifier` (flags + `YES`/`NO` + exit code) | +| Config | `appsettings.json` next to the exe (URLs, timeout, optional log path) | +| Secret | API key from env var `SCADABRIDGE_API_KEY` — never in a file | +| Packaging | Self-contained **Native AOT**, `win-x64` (fast startup for per-invocation use) | +| Failover | Comma-list of base URLs; advance **only on connect failure** | +| Location | New project `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier`, in `ZB.MOM.WW.ScadaBridge.slnx` | +| Implementation | Approach A — zero-dependency single-file; hand-rolled arg parser, `System.Text.Json` source-gen, raw `HttpClient` | +| Exe name | `AssemblyName = WWNotifier` (literal drop-in for Delmia's existing call path) | + +## Architecture + +Single-purpose console app, no DI/generic host. One `Program.cs` orchestrates four small pieces: + +1. **Arg parser** — hand-rolled, the 6 legacy flags → an in-memory request model. +2. **Config loader** — reads `appsettings.json` (via `System.Text.Json` source generator) → POCO; + reads the API key from the environment. +3. **Notifier** — builds the JSON payload and runs the failover POST loop behind a small seam + (interface/delegate) so the loop + result mapping are unit-testable without real HTTP. +4. **Result reporter** — maps the outcome to the `YES`/`NO` + exit-code contract (stdout) and writes + diagnostics (stderr + optional log file). + +Zero third-party NuGet dependencies (BCL only) to keep the Native-AOT surface minimal and trim-clean. + +## CLI contract (drop-in parity) + +| Short | Long | Required | → payload field | +|---|---|---|---| +| `-d` | `--downloadpath` | yes | `DownloadPath` | +| `-m` | `--machine` | yes | `MachineCode` | +| `-w` | `--workorder` | yes | `WorkOrderNumber` | +| `-p` | `--partnumber` | yes | `PartNumber` | +| `-s` | `--seqop` | no | `JobStepNumber` | +| `-u` | `--username` | no | `Username` | + +- **stdout:** exactly `YES` on success, or `NO` followed by a reason line on failure. Nothing else is + written to stdout (Delmia parses it). +- **exit code:** `0` on success, `-1` on failure (matches the legacy `Environment.ExitCode`). +- A missing required flag → `NO` + reason, exit `-1`, **no** HTTP attempt. + +## Configuration & secret + +`appsettings.json` placed next to the exe, loaded directly into a small POCO with a +`JsonSerializerContext` source generator (no reflection-based binder): + +```json +{ + "ScadaBridge": { + "BaseUrls": "http://host-a:8085,http://host-b:8085", + "TimeoutSeconds": 30, + "LogPath": "logs/delmia-notifier.log" + } +} +``` + +- `BaseUrls` — comma-separated failover list (legacy-style). The method path + `/api/DelmiaRecipeDownload` is appended by the app; each entry is a base URL only. +- `TimeoutSeconds` — per-attempt `HttpClient.Timeout` (default 30). +- `LogPath` — optional diagnostic log file (relative to the exe). Omit to log to stderr only. +- **API key** comes from `SCADABRIDGE_API_KEY`. If unset/empty → `NO` + "API key not configured", + exit `-1`, no attempt. The key is never read from or written to a file. + +## Request / response + +- Per attempt: `POST {baseUrl}/api/DelmiaRecipeDownload`, header `X-API-Key: `, + `Content-Type: application/json`, body = the flat `RecipeDownload` JSON. +- DTOs (local, source-gen serializable): + - `RecipeDownload { MachineCode, DownloadPath, WorkOrderNumber, PartNumber, JobStepNumber, Username }` + - `RecipeDownloadResult { bool Result, string ResultText }` + +## Failover (connect-failure only) + +Try each base URL in order; advance to the next **only when the attempt fails to connect** (no HTTP +response came back). A node that responds at all is authoritative — its answer is final. + +| Attempt outcome | Failover? | Final result | +|---|---|---| +| **No response** — connection refused/reset, DNS failure, TLS error, or timeout | **Yes** → next URL | only if *all* URLs fail to connect → `NO` + last connection error, exit `-1` | +| HTTP 2xx + `Result == true` | No — stop | `YES`, exit `0` | +| HTTP 2xx + `Result == false` | No — stop | `NO` + `ResultText`, exit `-1` | +| HTTP non-2xx (401/403/4xx/**5xx**) | No — stop | `NO` + status/error, exit `-1` | + +> Deliberate consequence: a `5xx` from the first node is reported as a failure, **not** rolled over +> to the next node — failover is strictly for unreachable nodes. (Revisit only if operations want +> `5xx`/`503` to also fail over.) + +## Error handling & logging + +- **stdout is reserved** for the `YES`/`NO` contract. All diagnostics — per-URL attempt, status code, + exception detail, which URL answered — go to **stderr** and, if `LogPath` is set, an appended log + file. Hand-written; no logging dependency. +- Failure reasons surfaced on the `NO` reason line: missing required arg, missing API key, no + `BaseUrls` configured, or "all URLs unreachable: \". +- The HTTP call sits behind a tiny seam so the failover/result logic is unit-tested without network. + +## Project layout & packaging + +``` +src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ + ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj + Program.cs # entry, arg parse, orchestration, result reporting + Config.cs # POCO + loader + RecipeDownload.cs # request DTO + RecipeDownloadResult.cs # response DTO + NotifierJsonContext.cs # JsonSerializerContext (source gen) + appsettings.json # copied to output +``` + +- `.csproj`: `net10.0`, `OutputType=Exe`, `AssemblyName=WWNotifier`, `PublishAot=true`, + `RuntimeIdentifier=win-x64`, `InvariantGlobalization=true`, AOT/trim analyzer warnings treated as + errors. Added to `ZB.MOM.WW.ScadaBridge.slnx`. +- **Build note:** the AOT `win-x64` native exe must be published **on Windows** + (`dotnet publish -c Release -r win-x64`) with the MSVC build tools present — Native AOT does not + cross-compile from macOS/Linux. Managed `dotnet build` / `dotnet test` still run cross-platform for + development and CI of the logic. + +## Testing + +`tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests` (xUnit), all logic-level (no AOT needed, runs +cross-platform): + +- Arg parsing: all-flags, required-missing → failure, optional omitted. +- Config: comma-split of `BaseUrls`, defaults (`TimeoutSeconds`), missing file/section. +- Payload mapping: flags → `RecipeDownload` JSON (field-for-field). +- Result mapping: `Result true/false` and non-2xx → correct stdout + exit code. +- Failover loop (via the HTTP seam / a fake handler): connect-failure advances; first responding node + is final; `Result==false` does **not** advance; all-unreachable → `NO` + last error. +- Missing/empty `SCADABRIDGE_API_KEY` → fail-fast, no attempt. +- Manual live smoke against `wonder-app-vd03` (`/api/DelmiaRecipeDownload`) from the Windows build, + mirroring the earlier `curl` verification. + +## Out of scope (YAGNI) + +- No retry/backoff beyond the connect-failover loop; no Polly. +- No DI/generic host, no `Microsoft.Extensions.*`. +- No support for other inbound methods — recipe download only. +- No DPAPI/secret-store integration (env var is the agreed mechanism); revisit if required.