docs(delmia-notifier): design for DelmiaNotifier console app (WWNotifier modern replacement)

Compact Native-AOT win-x64 console app DELMIA invokes to notify ScadaBridge of a
recipe download via POST /api/DelmiaRecipeDownload (X-API-Key). Drop-in CLI/output
parity with legacy WWNotifier; appsettings.json + SCADABRIDGE_API_KEY env var;
comma-list base URLs with connect-failure-only failover.
This commit is contained in:
Joseph Doherty
2026-06-26 04:55:40 -04:00
parent 8a78e759c0
commit 0008ca891c
@@ -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: <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: <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: \<last error\>".
- 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.