Merge R2-02 ResilienceConfig hardening (arch-review round 2) [PR #431]
Findings 01/S-6 (High — clamp hostile ResilienceConfig, no ValidationException driver-brick), 01/U-6 (delete dead bulkhead knob), 01/S-8=03/S12 (Write/Ack non-idempotent + retry->0), 04/C-7 (JsonObject round-trip preserves unknown keys), 01/S-7=03/S13 (per-Create options-generation kills respawn cache race). T15/T18 deferred. STATUS.md conflict resolved additively. Build clean.
This commit is contained in:
+49
-9
@@ -6,13 +6,23 @@
|
||||
<p class="form-text mb-3">Blank fields use the driver type's stability-tier defaults
|
||||
(see <span class="mono">docs/v2/driver-stability.md</span>). Set only what you need to override.</p>
|
||||
|
||||
@if (_m.ParseFailed)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<strong>Stored resilience config could not be parsed.</strong>
|
||||
Editing is locked so an accidental keystroke can't overwrite it. Fix the stored JSON directly,
|
||||
or discard it and start from tier defaults.
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" @onclick="DiscardAsync">
|
||||
Discard stored JSON and start blank
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><label class="form-label">Bulkhead max concurrent</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="_m.BulkheadMaxConcurrent" @bind:after="EmitAsync" placeholder="tier default" /></div>
|
||||
<div class="col-md-4"><label class="form-label">Bulkhead max queue</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="_m.BulkheadMaxQueue" @bind:after="EmitAsync" placeholder="tier default" /></div>
|
||||
<div class="col-md-4"><label class="form-label">Recycle interval (s, Tier C only)</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="_m.RecycleIntervalSeconds" @bind:after="EmitAsync" placeholder="none" /></div>
|
||||
<input type="number" class="form-control form-control-sm" @bind="_m.RecycleIntervalSeconds" @bind:after="EmitAsync" placeholder="none" disabled="@_m.ParseFailed" /></div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap mt-3">
|
||||
@@ -22,20 +32,37 @@
|
||||
@foreach (var cap in ResilienceFormModel.Capabilities)
|
||||
{
|
||||
var row = _m.Policies[cap];
|
||||
var writeShaped = cap is "Write" or "AlarmAcknowledge";
|
||||
<tr>
|
||||
<td class="mono">@cap</td>
|
||||
<td><input type="number" class="form-control form-control-sm" @bind="row.TimeoutSeconds" @bind:after="EmitAsync" placeholder="default" /></td>
|
||||
<td><input type="number" class="form-control form-control-sm" @bind="row.RetryCount" @bind:after="EmitAsync" placeholder="default" /></td>
|
||||
<td><input type="number" class="form-control form-control-sm" @bind="row.BreakerFailureThreshold" @bind:after="EmitAsync" placeholder="default" /></td>
|
||||
<td><input type="number" class="form-control form-control-sm" @bind="row.TimeoutSeconds" @bind:after="EmitAsync" placeholder="default" disabled="@_m.ParseFailed" /></td>
|
||||
<td><input type="number" class="form-control form-control-sm" @bind="row.RetryCount" @bind:after="EmitAsync" placeholder="@(writeShaped ? "never" : "default")" disabled="@(writeShaped || _m.ParseFailed)" title="@(writeShaped ? "Writes/acks never retry — per-tag opt-in not yet available" : null)" /></td>
|
||||
<td><input type="number" class="form-control form-control-sm" @bind="row.BreakerFailureThreshold" @bind:after="EmitAsync" placeholder="default" disabled="@_m.ParseFailed" /></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@{ var _warnings = _m.Validate(); }
|
||||
@if (_warnings.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning mt-3" role="alert">
|
||||
<strong>Out-of-range values will be clamped to a safe value on deploy:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var w in _warnings)
|
||||
{
|
||||
<li>@w</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<details class="mt-3">
|
||||
<summary class="small text-muted">Raw JSON (advanced)</summary>
|
||||
<pre class="form-control form-control-sm mono mt-2" style="white-space:pre-wrap;min-height:3rem;">@(_m.ToJson() ?? "(null — all tier defaults)")</pre>
|
||||
@* Render the bound STORED value, not the re-serialized model — shows the truth in both the
|
||||
healthy and the malformed case. *@
|
||||
<pre class="form-control form-control-sm mono mt-2" style="white-space:pre-wrap;min-height:3rem;">@(ResilienceConfig ?? "(null — all tier defaults)")</pre>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
@@ -59,9 +86,22 @@
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
// Never emit while the stored config is unparseable — the section is locked, and an unrelated
|
||||
// keystroke must never silently overwrite what we couldn't read (04/C-7).
|
||||
if (_m.ParseFailed) return;
|
||||
|
||||
var json = _m.ToJson();
|
||||
_lastParsed = json;
|
||||
ResilienceConfig = json;
|
||||
await ResilienceConfigChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task DiscardAsync()
|
||||
{
|
||||
// Explicit, visible destruction — replace the unparseable blob with a blank (tier-default) config.
|
||||
_m = new ResilienceFormModel();
|
||||
_lastParsed = null;
|
||||
ResilienceConfig = null;
|
||||
await ResilienceConfigChanged.InvokeAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
+155
-69
@@ -1,23 +1,28 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable, all-nullable form model for the driver resilience override. Binds the typed
|
||||
/// fields in DriverResilienceSection; null/blank = "use the driver's tier default", so a
|
||||
/// blank form serializes back to null (preserving DriverInstance.ResilienceConfig = null).
|
||||
/// Emits / reads the exact override JSON shape DriverResilienceOptionsParser consumes.
|
||||
/// Mutable, all-nullable form model for the driver resilience override. Binds the typed fields in
|
||||
/// DriverResilienceSection; null/blank = "use the driver's tier default", so a blank form serializes
|
||||
/// back to null (preserving DriverInstance.ResilienceConfig = null). Emits / reads the exact override
|
||||
/// JSON shape DriverResilienceOptionsParser consumes.
|
||||
/// <para>
|
||||
/// Round-trip is <b>non-lossy</b> (04/C-7): the stored JSON is kept as a <see cref="JsonObject"/> bag,
|
||||
/// and <see cref="ToJson"/> overlays only the known fields onto that bag — so unknown top-level keys,
|
||||
/// unknown capability entries, and unknown per-policy fields all survive a load→save, mirroring the
|
||||
/// driver tag editors' preserve-unknown discipline (<c>TagConfigJson</c>). A stored blob that could not
|
||||
/// be parsed sets <see cref="ParseFailed"/> and is <b>never</b> overwritten — <see cref="ToJson"/>
|
||||
/// returns the original text verbatim.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ResilienceFormModel
|
||||
{
|
||||
public static readonly string[] Capabilities =
|
||||
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"];
|
||||
|
||||
/// <summary>Gets or sets the bulkhead max-concurrency override; null = use the tier default.</summary>
|
||||
public int? BulkheadMaxConcurrent { get; set; }
|
||||
/// <summary>Gets or sets the bulkhead max-queue-length override; null = use the tier default.</summary>
|
||||
public int? BulkheadMaxQueue { get; set; }
|
||||
/// <summary>Gets or sets the driver recycle-interval override, in seconds; null = use the tier default.</summary>
|
||||
public int? RecycleIntervalSeconds { get; set; }
|
||||
|
||||
@@ -26,6 +31,18 @@ public sealed class ResilienceFormModel
|
||||
public Dictionary<string, CapabilityRow> Policies { get; set; } =
|
||||
Capabilities.ToDictionary(c => c, _ => new CapabilityRow(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// True when the stored ResilienceConfig could not be parsed as JSON. The section locks editing and
|
||||
/// <see cref="ToJson"/> returns <see cref="RawStoredJson"/> unchanged so an unrelated keystroke can
|
||||
/// never silently overwrite an unreadable column.
|
||||
/// </summary>
|
||||
public bool ParseFailed { get; private set; }
|
||||
|
||||
/// <summary>The original stored text when <see cref="ParseFailed"/> — returned verbatim by <see cref="ToJson"/>.</summary>
|
||||
public string? RawStoredJson { get; private set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Per-capability timeout/retry/breaker override row; null fields fall back to the tier default.</summary>
|
||||
public sealed class CapabilityRow
|
||||
{
|
||||
@@ -39,88 +56,157 @@ public sealed class ResilienceFormModel
|
||||
public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
|
||||
private static readonly JsonSerializerOptions WriteOpts = new()
|
||||
/// <summary>
|
||||
/// Range-validate the per-capability overrides against <see cref="ResiliencePolicyRanges"/> and return
|
||||
/// one human-readable message per violation (naming capability + field + legal range). This is
|
||||
/// authoring-time <b>feedback</b>, not the enforcement layer — the parser's clamp is the authoritative
|
||||
/// guard, so the form still emits. Mirrors the driver tag editors' <c>Validate()</c> idiom (01/S-6).
|
||||
/// </summary>
|
||||
/// <returns>A list of range-violation messages; empty when every override is in range or blank.</returns>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
var msgs = new List<string>();
|
||||
foreach (var (cap, row) in Policies)
|
||||
{
|
||||
if (row.TimeoutSeconds is { } t &&
|
||||
(t < ResiliencePolicyRanges.MinTimeoutSeconds || t > ResiliencePolicyRanges.MaxTimeoutSeconds))
|
||||
msgs.Add($"{cap} timeout must be between {ResiliencePolicyRanges.MinTimeoutSeconds} and " +
|
||||
$"{ResiliencePolicyRanges.MaxTimeoutSeconds} seconds (got {t}).");
|
||||
|
||||
/// <summary>Parses the override JSON into a form model; malformed or blank JSON yields an empty (all-default) form.</summary>
|
||||
if (row.RetryCount is { } r &&
|
||||
(r < ResiliencePolicyRanges.MinRetryCount || r > ResiliencePolicyRanges.MaxRetryCount))
|
||||
msgs.Add($"{cap} retries must be between {ResiliencePolicyRanges.MinRetryCount} and " +
|
||||
$"{ResiliencePolicyRanges.MaxRetryCount} (got {r}).");
|
||||
|
||||
if (row.BreakerFailureThreshold is { } b &&
|
||||
(b < ResiliencePolicyRanges.MinBreakerFailureThreshold || b == 1 ||
|
||||
b > ResiliencePolicyRanges.MaxBreakerFailureThreshold))
|
||||
msgs.Add($"{cap} breaker threshold must be 0 (off) or between " +
|
||||
$"{ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold} and " +
|
||||
$"{ResiliencePolicyRanges.MaxBreakerFailureThreshold} (got {b}).");
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the override JSON into a form model, preserving every key in an internal bag. Malformed JSON
|
||||
/// sets <see cref="ParseFailed"/> + <see cref="RawStoredJson"/> and yields an otherwise-empty model.
|
||||
/// </summary>
|
||||
/// <param name="json">The raw resilience-override JSON, or null/blank if there is no override.</param>
|
||||
/// <returns>A populated form model, or an all-default one when <paramref name="json"/> is blank or malformed.</returns>
|
||||
/// <returns>A populated form model.</returns>
|
||||
public static ResilienceFormModel FromJson(string? json)
|
||||
{
|
||||
var model = new ResilienceFormModel();
|
||||
if (string.IsNullOrWhiteSpace(json)) return model;
|
||||
|
||||
Shape? shape;
|
||||
try { shape = JsonSerializer.Deserialize<Shape>(json, ReadOpts); }
|
||||
catch (JsonException) { return model; } // malformed -> empty form; raw view (next task) shows the text
|
||||
if (shape is null) return model;
|
||||
JsonObject bag;
|
||||
try
|
||||
{
|
||||
bag = JsonNode.Parse(json) as JsonObject ?? new JsonObject();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
model.ParseFailed = true;
|
||||
model.RawStoredJson = json;
|
||||
return model;
|
||||
}
|
||||
|
||||
model.BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent;
|
||||
model.BulkheadMaxQueue = shape.BulkheadMaxQueue;
|
||||
model.RecycleIntervalSeconds = shape.RecycleIntervalSeconds;
|
||||
if (shape.CapabilityPolicies is not null)
|
||||
foreach (var (cap, p) in shape.CapabilityPolicies)
|
||||
if (model.Policies.TryGetValue(cap, out var row))
|
||||
model._bag = bag;
|
||||
model.RecycleIntervalSeconds = GetIntOrNull(bag, "recycleIntervalSeconds");
|
||||
|
||||
if (bag.TryGetPropertyValue("capabilityPolicies", out var cpNode) && cpNode is JsonObject cp)
|
||||
{
|
||||
foreach (var (capName, polNode) in cp)
|
||||
{
|
||||
// Known capability names populate rows (reading only the known per-policy fields — unknown
|
||||
// per-policy fields stay untouched in the bag). Unknown capability entries stay in the bag.
|
||||
if (polNode is JsonObject pol && model.Policies.TryGetValue(capName, out var row))
|
||||
{
|
||||
row.TimeoutSeconds = p.TimeoutSeconds;
|
||||
row.RetryCount = p.RetryCount;
|
||||
row.BreakerFailureThreshold = p.BreakerFailureThreshold;
|
||||
row.TimeoutSeconds = GetIntOrNull(pol, "timeoutSeconds");
|
||||
row.RetryCount = GetIntOrNull(pol, "retryCount");
|
||||
row.BreakerFailureThreshold = GetIntOrNull(pol, "breakerFailureThreshold");
|
||||
}
|
||||
}
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>Emit only the non-null overrides; returns null when nothing is overridden.</summary>
|
||||
/// <returns>The serialized override JSON, or null when no field in this form is overridden.</returns>
|
||||
/// <summary>
|
||||
/// Overlay the model's non-null known values onto the preserved bag and serialize. Returns null when
|
||||
/// the result is empty (blank form = tier defaults). When <see cref="ParseFailed"/>, returns the
|
||||
/// original unparseable text unchanged.
|
||||
/// </summary>
|
||||
/// <returns>The serialized override JSON, the original text when parse failed, or null when nothing is set.</returns>
|
||||
public string? ToJson()
|
||||
{
|
||||
var caps = Policies
|
||||
.Where(kv => !kv.Value.IsEmpty)
|
||||
.ToDictionary(kv => kv.Key, kv => new PolicyShape
|
||||
{
|
||||
TimeoutSeconds = kv.Value.TimeoutSeconds,
|
||||
RetryCount = kv.Value.RetryCount,
|
||||
BreakerFailureThreshold = kv.Value.BreakerFailureThreshold,
|
||||
});
|
||||
if (ParseFailed) return RawStoredJson;
|
||||
|
||||
var hasAny = BulkheadMaxConcurrent is not null || BulkheadMaxQueue is not null
|
||||
|| RecycleIntervalSeconds is not null || caps.Count > 0;
|
||||
if (!hasAny) return null;
|
||||
SetOrRemoveInt(_bag, "recycleIntervalSeconds", RecycleIntervalSeconds);
|
||||
|
||||
var shape = new Shape
|
||||
var cp = _bag.TryGetPropertyValue("capabilityPolicies", out var cpNode) && cpNode is JsonObject existing
|
||||
? existing
|
||||
: null;
|
||||
|
||||
foreach (var (cap, row) in Policies)
|
||||
{
|
||||
BulkheadMaxConcurrent = BulkheadMaxConcurrent,
|
||||
BulkheadMaxQueue = BulkheadMaxQueue,
|
||||
RecycleIntervalSeconds = RecycleIntervalSeconds,
|
||||
CapabilityPolicies = caps.Count > 0 ? caps : null,
|
||||
};
|
||||
return JsonSerializer.Serialize(shape, WriteOpts);
|
||||
// Find this known capability's existing policy object (case-insensitive), else its canonical key.
|
||||
var polKey = cap;
|
||||
JsonObject? pol = null;
|
||||
if (cp is not null)
|
||||
{
|
||||
foreach (var (k, v) in cp)
|
||||
{
|
||||
if (string.Equals(k, cap, StringComparison.OrdinalIgnoreCase) && v is JsonObject vo)
|
||||
{
|
||||
polKey = k;
|
||||
pol = vo;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (row.IsEmpty)
|
||||
{
|
||||
// Clear the known fields; drop the policy entry only if no unknown fields remain.
|
||||
if (pol is not null)
|
||||
{
|
||||
pol.Remove("timeoutSeconds");
|
||||
pol.Remove("retryCount");
|
||||
pol.Remove("breakerFailureThreshold");
|
||||
if (pol.Count == 0) cp!.Remove(polKey);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (cp is null)
|
||||
{
|
||||
cp = new JsonObject();
|
||||
_bag["capabilityPolicies"] = cp;
|
||||
}
|
||||
if (pol is null)
|
||||
{
|
||||
pol = new JsonObject();
|
||||
cp[polKey] = pol;
|
||||
}
|
||||
SetOrRemoveInt(pol, "timeoutSeconds", row.TimeoutSeconds);
|
||||
SetOrRemoveInt(pol, "retryCount", row.RetryCount);
|
||||
SetOrRemoveInt(pol, "breakerFailureThreshold", row.BreakerFailureThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
if (cp is not null && cp.Count == 0) _bag.Remove("capabilityPolicies");
|
||||
|
||||
return _bag.Count == 0 ? null : _bag.ToJsonString();
|
||||
}
|
||||
|
||||
/// <summary>Wire shape of the resilience-override JSON, as consumed by DriverResilienceOptionsParser.</summary>
|
||||
private sealed class Shape
|
||||
{
|
||||
/// <summary>Gets or sets the bulkhead max-concurrency override.</summary>
|
||||
public int? BulkheadMaxConcurrent { get; set; }
|
||||
/// <summary>Gets or sets the bulkhead max-queue-length override.</summary>
|
||||
public int? BulkheadMaxQueue { get; set; }
|
||||
/// <summary>Gets or sets the driver recycle-interval override, in seconds.</summary>
|
||||
public int? RecycleIntervalSeconds { get; set; }
|
||||
/// <summary>Gets or sets the per-capability overrides, keyed by capability name.</summary>
|
||||
public Dictionary<string, PolicyShape>? CapabilityPolicies { get; set; }
|
||||
}
|
||||
private static int? GetIntOrNull(JsonObject o, string name)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<int>(out var i)
|
||||
? i
|
||||
: null;
|
||||
|
||||
/// <summary>Wire shape of a single capability's timeout/retry/breaker override.</summary>
|
||||
private sealed class PolicyShape
|
||||
private static void SetOrRemoveInt(JsonObject o, string name, int? value)
|
||||
{
|
||||
/// <summary>Gets or sets the timeout override, in seconds.</summary>
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
/// <summary>Gets or sets the retry-count override.</summary>
|
||||
public int? RetryCount { get; set; }
|
||||
/// <summary>Gets or sets the circuit-breaker failure-threshold override.</summary>
|
||||
public int? BreakerFailureThreshold { get; set; }
|
||||
if (value is null) o.Remove(name);
|
||||
else o[name] = JsonValue.Create(value.Value);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user