chore(secrets): adopt ZB.MOM.WW.Secrets 0.6.2 and close the pre-host guard gap
0.6.x refuses a secret store whose path is relative or inside the content root,
because a store in the deployment directory is destroyed by an ordinary upgrade —
the failure that wiped the MxGateway API-key store on 2026-08-09 and read as an
auth outage rather than a deployment error.
The pin alone would not have protected this repo. Program.cs expands ${secret:}
before the host exists, composing secrets into a throwaway ServiceCollection with
no IHostEnvironment, so the guard would not run at the moment the migrator creates
the store. That composition now lives in SecretsRegistration with an explicit
content root — resolved to match what the host resolves later, including the
Windows-Service case where the pre-host CWD is still system32 — and is covered by
PreHostSecretsContentRootTests, verified by simulating the regression and
confirming it fails on the leftover file rather than on the exception.
The docker rig needed a fix too: /app/data is absolute but inside the container's
content root, so all 8 nodes would have failed to boot. Each node's data directory
is now mounted a second time at /data; same host directory, so existing stores
carry over untouched.
Verified: build clean, 29 test assemblies green (Playwright's 159 failures are the
pre-existing SEC-36 login baseline). Not yet deployed — the rig runs the old
config until someone redeploys.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Hosting.WindowsServices;
|
||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection;
|
||||
@@ -185,6 +186,65 @@ public static class SecretsRegistration
|
||||
&& ResolveReplicationMode(config) == SecretsReplicationMode.Grpc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the content root the HOST will use, from outside the host — the Layer-A
|
||||
/// <c>${secret:}</c> expander in <c>Program.cs</c> runs before any builder exists, so
|
||||
/// <c>builder.Environment.ContentRootPath</c> is not available to it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors the framework's own resolution order. The Windows-Service branch is the one that
|
||||
/// is easy to get wrong: <c>UseWindowsService</c> sets both the current directory and the
|
||||
/// content root to <see cref="AppContext.BaseDirectory"/>, but only once the host is built —
|
||||
/// at expander time the process working directory is still <c>C:\Windows\system32</c>, so
|
||||
/// taking the current directory there would compare the store path against the wrong root.
|
||||
/// </remarks>
|
||||
/// <returns>The absolute content-root path the host will resolve.</returns>
|
||||
public static string ResolveHostContentRoot() =>
|
||||
Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT")
|
||||
?? Environment.GetEnvironmentVariable("DOTNET_CONTENTROOT")
|
||||
?? (WindowsServiceHelpers.IsWindowsService()
|
||||
? AppContext.BaseDirectory
|
||||
: Directory.GetCurrentDirectory());
|
||||
|
||||
/// <summary>
|
||||
/// Composes the SQLite secrets stack for the Layer-A pre-host <c>${secret:}</c> expander.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This exists as a named method rather than an inline <c>AddZbSecrets</c> call so that the
|
||||
/// content-root argument is covered by a test (<c>PreHostSecretsContentRootTests</c>). The
|
||||
/// argument is load-bearing and its absence is SILENT: the expander composes into a throwaway
|
||||
/// <see cref="IServiceCollection"/> that has no <c>IHostEnvironment</c>, so without it the
|
||||
/// under-content-root rule does not run, the migrator creates the store at the rejected path,
|
||||
/// and the boot then fails once the real host registers <c>IHostEnvironment</c> — leaving an
|
||||
/// empty database behind. That "the file is there, it's just empty" artifact is the confusing
|
||||
/// half of the 2026-08-09 MxGateway outage the guard exists to prevent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A correctly configured path shows NO symptom either way, so this cannot be verified by
|
||||
/// observing a clean boot — the exposure is that the guard is not running when the store is
|
||||
/// created, not that any particular path is wrong.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The throwaway expander service collection.</param>
|
||||
/// <param name="config">Application configuration.</param>
|
||||
/// <param name="contentRoot">The host's content root, from <see cref="ResolveHostContentRoot"/>.</param>
|
||||
/// <returns>The same collection, for chaining.</returns>
|
||||
public static IServiceCollection AddPreHostSqliteExpander(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
string contentRoot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentRoot);
|
||||
|
||||
// Do NOT drop the third argument. The 3-argument overload still compiles and silently
|
||||
// reopens the gap described above; the regression would arrive disguised as removing a
|
||||
// redundant parameter.
|
||||
return services.AddZbSecrets(config, SecretsSectionPath, contentRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one definition of "a Central node in Grpc mode has a usable shared-store connection
|
||||
/// string", shared by <see cref="AddScadaBridgeSecrets"/> and the Layer-A expander in
|
||||
|
||||
Reference in New Issue
Block a user