fix(delmia-notifier)+chore(docker): review follow-ups — trim the env key like the file key, log which source supplied the key (never the key), hoist the not-configured message; README warns never to commit a filled-in appsettings.json; design-doc body lines updated; seed-delmia-api.sh no longer hides CLI errors and notes key names are not unique

Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
Joseph Doherty
2026-09-02 12:52:56 -04:00
parent 83f7bbfb50
commit f4363e0b4b
6 changed files with 80 additions and 18 deletions
+9 -5
View File
@@ -4,7 +4,9 @@ set -euo pipefail
# Seed the DelmiaRecipeDownload inbound method (prod's script verbatim, minus its dead `suf` # Seed the DelmiaRecipeDownload inbound method (prod's script verbatim, minus its dead `suf`
# line — see docs/api-specs/Delmia-Recipe-Download-API.md §4) and one API key scoped to it. # line — see docs/api-specs/Delmia-Recipe-Download-API.md §4) and one API key scoped to it.
# Prints the one-time token as DELMIA_API_KEY=... — capture it; it cannot be retrieved again. # Prints the one-time token as DELMIA_API_KEY=... — capture it; it cannot be retrieved again.
# Not idempotent (method name must be unique): delete with `api-method delete --id`. # Not idempotent (method name must be unique): delete with `api-method delete --id`. Key display
# names are NOT unique — re-running mints another key with the same name; delete the stale one with
# `security api-key delete --key-id`.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -12,6 +14,8 @@ CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}") A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
KEY_NAME="${KEY_NAME:-delmia-notifier}" KEY_NAME="${KEY_NAME:-delmia-notifier}"
json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; }
read -r -d '' METHOD_CODE <<'CS' || true read -r -d '' METHOD_CODE <<'CS' || true
try { try {
System.Func<string, object> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null; System.Func<string, object> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
@@ -31,13 +35,13 @@ try {
CS CS
echo "1/2 Creating inbound method DelmiaRecipeDownload (timeout 45 s)..." echo "1/2 Creating inbound method DelmiaRecipeDownload (timeout 45 s)..."
METHOD_JSON=$("$CLI" api-method create "${A[@]}" --name DelmiaRecipeDownload --timeout 45 \ METHOD_ID=$("$CLI" api-method create "${A[@]}" --name DelmiaRecipeDownload --timeout 45 \
--parameters '{"type":"object","properties":{"MachineCode":{"type":"string"},"DownloadPath":{"type":"string"},"WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"},"JobStepNumber":{"type":"string"},"Username":{"type":"string"}},"required":[]}' \ --parameters '{"type":"object","properties":{"MachineCode":{"type":"string"},"DownloadPath":{"type":"string"},"WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"},"JobStepNumber":{"type":"string"},"Username":{"type":"string"}},"required":[]}' \
--return-def '{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}' \ --return-def '{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}' \
--script "$METHOD_CODE") --script "$METHOD_CODE" | json_id)
echo " $(echo "$METHOD_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("id=%s name=%s" % (d["id"], d["name"]))')" echo " id=$METHOD_ID name=DelmiaRecipeDownload"
echo "2/2 Creating API key '$KEY_NAME' scoped to DelmiaRecipeDownload..." echo "2/2 Creating API key '$KEY_NAME' scoped to DelmiaRecipeDownload..."
KEY_JSON=$("$CLI" security api-key create "${A[@]}" --name "$KEY_NAME" --methods DelmiaRecipeDownload 2>/dev/null) KEY_JSON=$("$CLI" security api-key create "${A[@]}" --name "$KEY_NAME" --methods DelmiaRecipeDownload)
TOKEN=$(echo "$KEY_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' 2>/dev/null || echo "$KEY_JSON" | grep -o 'sbk_[A-Za-z0-9_-]*' | head -1) TOKEN=$(echo "$KEY_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' 2>/dev/null || echo "$KEY_JSON" | grep -o 'sbk_[A-Za-z0-9_-]*' | head -1)
echo "DELMIA_API_KEY=$TOKEN" echo "DELMIA_API_KEY=$TOKEN"
@@ -78,8 +78,9 @@ Zero third-party NuGet dependencies (BCL only) to keep the Native-AOT surface mi
`/api/DelmiaRecipeDownload` is appended by the app; each entry is a base URL only. `/api/DelmiaRecipeDownload` is appended by the app; each entry is a base URL only.
- `TimeoutSeconds` — per-attempt `HttpClient.Timeout` (default 30). - `TimeoutSeconds` — per-attempt `HttpClient.Timeout` (default 30).
- `LogPath` — optional diagnostic log file (relative to the exe). Omit to log to stderr only. - `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", - **API key** comes from `SCADABRIDGE_API_KEY` first; since 2026-09-02 an optional `ScadaBridge:ApiKey`
exit `-1`, no attempt. The key is never read from or written to a file. in `appsettings.json` is the fallback when the variable is unset/blank (env wins). If neither is set →
`NO` + "API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)", exit `-1`, no attempt.
## Request / response ## Request / response
@@ -170,7 +171,7 @@ cross-platform):
- Result mapping: `Result true/false` and non-2xx → correct stdout + exit code. - 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 - 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. is final; `Result==false` does **not** advance; all-unreachable → `NO` + last error.
- Missing/empty `SCADABRIDGE_API_KEY` → fail-fast, no attempt. - Missing/empty `SCADABRIDGE_API_KEY` **and** blank `ScadaBridge:ApiKey` → fail-fast, no attempt; env-over-file precedence pinned (2026-09-02).
- Manual live smoke against `wonder-app-vd03` (`/api/DelmiaRecipeDownload`) from the Windows build, - Manual live smoke against `wonder-app-vd03` (`/api/DelmiaRecipeDownload`) from the Windows build,
mirroring the earlier `curl` verification. mirroring the earlier `curl` verification.
@@ -34,19 +34,41 @@ internal static class ConfigLoader
return baseUrls.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); return baseUrls.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
} }
/// <summary>Resolve the API key: <c>SCADABRIDGE_API_KEY</c> wins when non-blank, otherwise <c>ScadaBridge:ApiKey</c> from <paramref name="cfg"/> (trimmed); null when neither is set. Env accessor is injected for testability.</summary> /// <summary>Human-readable name of the environment source, for diagnostics (never carries key material).</summary>
public const string ApiKeySourceEnvironment = "environment (SCADABRIDGE_API_KEY)";
/// <summary>Human-readable name of the file source, for diagnostics (never carries key material).</summary>
public const string ApiKeySourceFile = "appsettings.json (ScadaBridge:ApiKey)";
/// <summary>Resolve the API key: <c>SCADABRIDGE_API_KEY</c> wins when non-blank, otherwise <c>ScadaBridge:ApiKey</c> from <paramref name="cfg"/>; both trimmed; null when neither is set. Env accessor is injected for testability.</summary>
/// <param name="envGet">Accessor used to read an environment variable by name.</param> /// <param name="envGet">Accessor used to read an environment variable by name.</param>
/// <param name="cfg">The loaded configuration, consulted only when the environment variable is unset/blank.</param> /// <param name="cfg">The loaded configuration, consulted only when the environment variable is unset/blank.</param>
/// <returns>The resolved API key, or <c>null</c> if neither source yields a non-blank value.</returns> /// <returns>The resolved API key, or <c>null</c> if neither source yields a non-blank value.</returns>
public static string? ResolveApiKey(Func<string, string?> envGet, NotifierConfig cfg) public static string? ResolveApiKey(Func<string, string?> envGet, NotifierConfig cfg) =>
ResolveApiKey(envGet, cfg, out _);
/// <summary>Same as <see cref="ResolveApiKey(Func{string, string?}, NotifierConfig)"/> but also reports which source supplied the key, so a stale environment variable silently shadowing an edited file is diagnosable from the log.</summary>
/// <param name="envGet">Accessor used to read an environment variable by name.</param>
/// <param name="cfg">The loaded configuration, consulted only when the environment variable is unset/blank.</param>
/// <param name="source"><see cref="ApiKeySourceEnvironment"/>, <see cref="ApiKeySourceFile"/>, or <c>null</c> when no source yielded a key.</param>
/// <returns>The resolved API key, or <c>null</c> if neither source yields a non-blank value.</returns>
public static string? ResolveApiKey(Func<string, string?> envGet, NotifierConfig cfg, out string? source)
{ {
var env = envGet(ApiKeyEnvVar); var env = envGet(ApiKeyEnvVar);
if (!string.IsNullOrWhiteSpace(env)) if (!string.IsNullOrWhiteSpace(env))
{ {
return env; source = ApiKeySourceEnvironment;
return env.Trim();
} }
var file = cfg.ScadaBridge.ApiKey; var file = cfg.ScadaBridge.ApiKey;
return string.IsNullOrWhiteSpace(file) ? null : file.Trim(); if (!string.IsNullOrWhiteSpace(file))
{
source = ApiKeySourceFile;
return file.Trim();
}
source = null;
return null;
} }
} }
@@ -5,6 +5,9 @@ internal static class Program
/// <summary>Entry point: loads config, sends the recipe-download notification with failover across the configured base URLs, and reports the legacy YES/NO stdout contract.</summary> /// <summary>Entry point: loads config, sends the recipe-download notification with failover across the configured base URLs, and reports the legacy YES/NO stdout contract.</summary>
/// <param name="args">Command-line arguments parsed into the recipe download payload.</param> /// <param name="args">Command-line arguments parsed into the recipe download payload.</param>
/// <returns>A task that resolves to the process exit code.</returns> /// <returns>A task that resolves to the process exit code.</returns>
/// <summary>Stdout reason + log line when neither key source is set; one literal so the two cannot drift.</summary>
private const string ApiKeyNotConfigured = "API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)";
public static async Task<int> Main(string[] args) public static async Task<int> Main(string[] args)
{ {
var stdout = Console.Out; var stdout = Console.Out;
@@ -39,13 +42,15 @@ internal static class Program
return Reporter.Report(false, "no BaseUrls configured", stdout); return Reporter.Report(false, "no BaseUrls configured", stdout);
} }
var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable, cfg); var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable, cfg, out var apiKeySource);
if (apiKey is null) if (apiKey is null)
{ {
log.Write("API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)"); log.Write(ApiKeyNotConfigured);
return Reporter.Report(false, "API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)", stdout); return Reporter.Report(false, ApiKeyNotConfigured, stdout);
} }
log.Write("api key source: " + apiKeySource);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(cfg.ScadaBridge.TimeoutSeconds) }; using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(cfg.ScadaBridge.TimeoutSeconds) };
var sender = new LoggingRecipeSender(new HttpRecipeSender(http, apiKey), log); var sender = new LoggingRecipeSender(new HttpRecipeSender(http, apiKey), log);
@@ -88,12 +88,16 @@ Placed next to the exe (copied to the output on build/publish):
The inbound API key is sent as `X-API-Key`. It is resolved from **`SCADABRIDGE_API_KEY`** first The inbound API key is sent as `X-API-Key`. It is resolved from **`SCADABRIDGE_API_KEY`** first
(recommended for production — the key never touches disk), falling back to **`ScadaBridge:ApiKey`** in (recommended for production — the key never touches disk), falling back to **`ScadaBridge:ApiKey`** in
`appsettings.json` when the variable is unset or blank. If neither is set → `NO` + `appsettings.json` when the variable is unset or blank (a blank file value counts as unset too; both
values are trimmed). The diagnostic log records which source supplied the key — never the key itself — so
a stale environment variable silently shadowing an edited file is visible. If neither is set → `NO` +
`API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)`, exit `-1`, no HTTP attempt. `API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)`, exit `-1`, no HTTP attempt.
Set the variable on the machine/account that DELMIA runs under (e.g. a system/user environment Set the variable on the machine/account that DELMIA runs under (e.g. a system/user environment
variable, or the service account's environment). If you use the file instead, restrict its ACL to the variable, or the service account's environment). If you use the file instead, restrict its ACL to the
account DELMIA runs under — the key is stored in plain text there. account DELMIA runs under — the key is stored in plain text there. Fill `ApiKey` in **only on the deployed
copy**: the repo's `appsettings.json` is tracked and copied to the build output, so a key pasted there to
test locally is one `git add` away from being committed. It must stay `""` in source control.
## Building & publishing (Native AOT, `win-x64`) ## Building & publishing (Native AOT, `win-x64`)
@@ -106,7 +110,8 @@ dotnet publish src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier -c Release -r win-x64
# → bin/Release/net10.0/win-x64/publish/WWNotifier.exe (+ appsettings.json) # → bin/Release/net10.0/win-x64/publish/WWNotifier.exe (+ appsettings.json)
``` ```
Copy `WWNotifier.exe` **and** `appsettings.json` to the Delmia host, set `SCADABRIDGE_API_KEY`, and Copy `WWNotifier.exe` **and** `appsettings.json` to the Delmia host, set `SCADABRIDGE_API_KEY` (or fill in
`ScadaBridge:ApiKey` on that copy), and
point `BaseUrls` at the central node(s). point `BaseUrls` at the central node(s).
> Managed `dotnet build` / `dotnet test` run cross-platform and are the development/CI gate; only the > Managed `dotnet build` / `dotnet test` run cross-platform and are the development/CI gate; only the
@@ -70,6 +70,31 @@ public class ConfigLoaderTests
Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => " ", cfg)); Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => " ", cfg));
} }
[Fact]
public void ResolveApiKey_trims_env_value_like_the_file_value()
{
Assert.Equal("sbk_env", ConfigLoader.ResolveApiKey(_ => " sbk_env \t", new NotifierConfig()));
}
[Fact]
public void ResolveApiKey_reports_which_source_supplied_the_key()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\"sbk_file\"}}");
ConfigLoader.ResolveApiKey(_ => "sbk_env", cfg, out var fromEnv);
ConfigLoader.ResolveApiKey(_ => null, cfg, out var fromFile);
ConfigLoader.ResolveApiKey(_ => null, new NotifierConfig(), out var none);
Assert.Equal(ConfigLoader.ApiKeySourceEnvironment, fromEnv);
Assert.Equal(ConfigLoader.ApiKeySourceFile, fromFile);
Assert.Null(none);
}
[Fact]
public void Load_leaves_ApiKey_null_when_absent()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"http://x\"}}");
Assert.Null(cfg.ScadaBridge.ApiKey);
}
[Fact] [Fact]
public void ResolveApiKey_returns_null_when_neither_source_set() public void ResolveApiKey_returns_null_when_neither_source_set()
{ {