31ca9407a6
The ScadaBridge central shared-store live gate proved SqlServerSecretsStoreMigrator's
"safe to run concurrently" claim false on a virgin database: Serializable isolation does
not serialize the IF NOT EXISTS metadata probes against another session's in-flight
CREATE, so of two central nodes booting together the loser died with SqlException 2714
("There is already an object named 'zbsecrets'") — the retry filter covered only
deadlock 1205. Losing such a race means the object now exists (the desired end state),
so 2714 (schema/table), 1913 (index) and 2627 (schema_version MERGE-insert PK) are now
retryable alongside 1205; the retry re-runs the idempotent IF NOT EXISTS path.
Classification scans the whole SqlException.Errors collection (2714 arrives stacked with
2759), the retry cap keeps genuine conflicts failing closed, and 2705 is deliberately
excluded (unreachable — columns live in a single CREATE TABLE statement). Predicate
extracted as an internal static pinned by offline tests; live suite gains an env-gated
4-concurrent-migrators virgin-database test. Six packages pushed to the Gitea feed at
0.5.1.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
147 lines
8.4 KiB
Markdown
147 lines
8.4 KiB
Markdown
# Changelog
|
|
|
|
All notable changes to the `ZB.MOM.WW.Secrets` family are recorded here. Format loosely
|
|
follows [Keep a Changelog](https://keepachangelog.com/); versions are shared across every
|
|
package in the family (see `Directory.Build.props`) even when a given release only touches
|
|
one of them.
|
|
|
|
## 0.5.1 — 2026-08-07
|
|
|
|
### Fixed
|
|
|
|
- **`ZB.MOM.WW.Secrets.Replicator.SqlServer` — virgin-database concurrent migrator race**
|
|
(present since 0.2.0; found by the ScadaBridge central shared-store live gate). The
|
|
migrator's doc claimed simultaneous provisioning of a virgin database converges, but its
|
|
retry filter covered only deadlock victims (1205): Serializable isolation does not
|
|
serialize the `IF NOT EXISTS` metadata probes against another session's in-flight
|
|
`CREATE`, so of two nodes booting together against an empty database the loser died with
|
|
`SqlException` 2714 ("There is already an object named 'zbsecrets'") and aborted its
|
|
host — observed live on an ordinary `docker compose up` of the ScadaBridge central pair.
|
|
The retry filter now also treats the lost-CREATE/lost-INSERT race outcomes as retryable —
|
|
2714 (schema/table already exists), 1913 (index already exists), 2627 (schema_version
|
|
MERGE-insert PK violation) — since losing such a race means the object now exists, which
|
|
is the desired end state; the retry re-runs the idempotent `IF NOT EXISTS` path and
|
|
proceeds. Classification scans the whole `SqlException.Errors` collection (a failed DDL
|
|
batch stacks follow-on errors such as 2759, so the retryable number is not guaranteed
|
|
first), and the retry cap (5) keeps a genuine conflict — e.g. an unrelated pre-existing
|
|
object colliding with the schema name — failing closed. 2705 (duplicate column) is
|
|
deliberately not retried: every column lives inside a single `CREATE TABLE` statement,
|
|
so no cross-session race can produce it. The error-number predicate is now an internal
|
|
static pinned by offline tests, and the live suite gains an N-concurrent-migrators
|
|
virgin-database test.
|
|
|
|
## 0.5.0 — 2026-08-07
|
|
|
|
### Added
|
|
|
|
- **`ZB.MOM.WW.Secrets.Replicator.Grpc` — hub endpoint failover for followers**
|
|
(scadaproj#4). `SecretsGrpcHubClientOptions` gains `FallbackEndpoints`: additional hub
|
|
addresses tried, in order, when a call to the primary `Endpoint` (or an earlier fallback)
|
|
fails with a transport-level gRPC error. The answering endpoint is sticky — a sweep that
|
|
failed over does not re-pay the dead endpoint's full `CallDeadline` on every subsequent
|
|
call — and rotation wraps, so a recovered primary is retried the next time the stuck-to
|
|
endpoint fails. Only `RpcException` triggers failover; cancellation and everything else
|
|
propagate untouched, and when every endpoint fails the last `RpcException` is rethrown so
|
|
the sweep degrades to last-known-good exactly as before. Each failover logs a Warning
|
|
naming the failed and next endpoint URIs (never any credential material).
|
|
- **Same-store requirement:** list only endpoints that serve the SAME store — for
|
|
ScadaBridge, the central pair over one shared SQL Server store. Endpoints backed by
|
|
independent stores turn a primary outage into a silent convergence stop (failing over
|
|
to an emptier hub "succeeds"), which is the exact defect scadaproj#4 recorded.
|
|
- Validation fails startup on a blank/relative fallback or one duplicating the primary
|
|
or another fallback (a duplicated endpoint silently halves the configured failover).
|
|
|
|
### Unchanged
|
|
|
|
- With `FallbackEndpoints` empty (the default), the registration graph is identical to
|
|
0.4.x — one keyed channel, one `GrpcSecretsHubClient`, no failover wrapper — and the wire
|
|
behavior of every call is byte-identical. Every other package in the family is
|
|
behaviorally unchanged in this release.
|
|
|
|
## 0.4.1 — 2026-08-07
|
|
|
|
### Fixed
|
|
|
|
- **`ZB.MOM.WW.Secrets.Replicator.Grpc` — the hub now logs its denials.** A ScadaBridge live
|
|
gate found that `SecretsHubAuthInterceptor` refused calls correctly but wrote nothing about
|
|
it: the only trace of a refusal was a framework `Grpc.AspNetCore.Server` Information line, so
|
|
a follower whose bearer token had been mis-rotated silently stopped converging and the hub
|
|
showed it only at INF. Every denial is now counted, and a `Warning` summarising the window is
|
|
emitted on the first denial after startup or after 60 quiet seconds — carrying the number of
|
|
denials since the previous summary plus a breakdown by cause (missing/malformed credential,
|
|
wrong credential, hub token unconfigured). A single probe is therefore visible immediately,
|
|
while a sustained attack is bounded to one log line per minute — the endpoint is
|
|
unauthenticated by construction, so a per-denial warning would be an unbounded log write
|
|
driven by an unauthenticated caller.
|
|
|
|
### Unchanged
|
|
|
|
- **Wire behavior is byte-identical.** The cause breakdown is server-side diagnostics only; every
|
|
rejection still returns the same uniform `Unauthenticated` status with the same detail, and the
|
|
credential comparison is still constant-time. No token material, presented or configured,
|
|
appears in any log line at any level.
|
|
- The existing warn-once-on-unconfigured-token line is unchanged and still emitted exactly once;
|
|
denials in that state also count toward the new summary's `unconfigured` category.
|
|
- Every other package in the family is behaviorally unchanged in this release.
|
|
|
|
## 0.4.0 — 2026-08-07
|
|
|
|
### Added
|
|
|
|
- **New package `ZB.MOM.WW.Secrets.Replicator.Grpc`** — a central-hosted `secrets_hub.v1`
|
|
gRPC hub for the sites-talk-to-central production topology (scadaproj#3; design doc
|
|
[`docs/plans/2026-08-07-secrets-production-topology-and-grpc-hub.md`](../docs/plans/2026-08-07-secrets-production-topology-and-grpc-hub.md)).
|
|
- **Pull-only by construction**: the proto exposes no write RPC. Updates originate at
|
|
central only; a site never pushes a secret upstream.
|
|
- **Fail-closed bearer auth** on the hub server: constant-time comparison, uniform denial
|
|
response (no signal distinguishing "wrong token" from "missing token"), a warn-once log
|
|
on an unconfigured/empty token rather than a silent open door.
|
|
- `GetSecrets` enforces a batch-size cap, rejecting an oversized request with
|
|
`ResourceExhausted` instead of serving it partially or unbounded.
|
|
- Site-side `GrpcSecretSyncService` sweeps the hub on an interval with **per-row parse
|
|
isolation** — one malformed/undecryptable row logs and is skipped, it does not fault the
|
|
whole sweep.
|
|
- Keyed `GrpcChannel` registration so multiple hub targets can be wired in one process
|
|
without channel collisions.
|
|
- **Offline behavior is last-known-good**: if the hub is unreachable, the site keeps
|
|
serving the most recently synced values rather than failing secret resolution.
|
|
|
|
### Unchanged
|
|
|
|
- `ZB.MOM.WW.Secrets.Abstractions`, `ZB.MOM.WW.Secrets`, `ZB.MOM.WW.Secrets.Ui`,
|
|
`ZB.MOM.WW.Secrets.Replicator.SqlServer`, and `ZB.MOM.WW.Secrets.Replicator.AkkaDotNet` are
|
|
behaviorally unchanged in this release — no breaking changes to any existing package.
|
|
|
|
## 0.3.0 — 2026-07-19
|
|
|
|
- Menu-driven Spectre.Console interactive operator console added to the `secret` CLI (list /
|
|
set / reveal / delete, reference audit & seed, KEK doctor, ciphertext-only bundle
|
|
export/import). See
|
|
[`docs/operations/interactive-console.md`](docs/operations/interactive-console.md).
|
|
|
|
## 0.2.3 — 2026-07-19
|
|
|
|
- `ZB.MOM.WW.Secrets.Ui`: fixed the delete-confirmation modal being permanently invisible on
|
|
every Bootstrap host (`ConfirmDeleteModal` used bare `class="modal"` with no shipped CSS;
|
|
Bootstrap's `.modal{display:none}` hid it everywhere but mxaccessgw). Fixed via a
|
|
`zb-secrets-modal*` class rename plus a component-emitted `<style>` block.
|
|
|
|
## 0.2.2 — 2026-07-18
|
|
|
|
- Fixed the Akka replicator's hosted-process DI deadlock (a circular singleton dependency
|
|
hidden behind factory lambdas that MS.DI's `StackGuard` turned into a silent cross-thread
|
|
hang rather than a stack overflow). New `DeferredSecretCacheInvalidator` defers the one
|
|
cycle-closing edge to first eviction.
|
|
|
|
## 0.2.1 — 2026-07-18
|
|
|
|
- Fixed the Akka replicator being silently inert since 0.2.0 (a `TryAdd` registration-order
|
|
bug published into a no-op sink and never spawned an actor). SQL-Server replication was
|
|
never affected.
|
|
|
|
## 0.2.0 — 2026-07-18
|
|
|
|
- Added clustered replication (G-7): `ZB.MOM.WW.Secrets.Replicator.SqlServer` (shared store,
|
|
or local-store-with-hub) and `ZB.MOM.WW.Secrets.Replicator.AkkaDotNet` (peer-to-peer over
|
|
distributed pub/sub).
|