feat(instance): native-alarm-source-override CSV bulk import (deferred #12) + doc fixes

Closes the operator parity gap the deferred-work register tracked as #12: native
alarm source overrides could only be set one-at-a-time, while attribute overrides
had a CSV bulk path. Adds an all-or-nothing CSV import for native sources.

- Commons: extract the RFC-4180 line splitter into a shared CsvLineSplitter
  (refactor OverrideCsvParser onto it — no behavior change, pinned by its tests);
  new NativeAlarmSourceOverrideCsvParser (header SourceName,Connection,
  SourceReference,Filter; blank = inherited).
- Commons: NativeAlarmSourceOverrideEntry + bulk SetInstanceNativeAlarmSource-
  OverridesCommand (auto-registered via the reflection command registry).
- ManagementService: Deployer-gated handler — flattens once, validates every
  source resolves + is unlocked + no duplicates up front, then upserts the whole
  batch under a single SaveChanges (true all-or-nothing txn). Added to the frozen
  authorization matrix; dispatch-coverage guard passes.
- CLI: `instance native-alarm-source import --instance-id --file` (parity with
  `instance import-overrides`) + README.
- Tests: native parser (Commons), CLI parse/entry mapping, 3 bulk-handler tests
  (happy path single-commit, locked-source reject, unresolved-source reject).

Also corrects a stale XML-doc line in ScriptRuntimeContext (WaitForAttribute
quality-gated mode is shipped, not "planned") and updates the deferred-work
register: marks #7/#13/#15/#16/#20 verified-resolved, #12 as CLI/API-shipped with
only the Central UI upload affordance still pending.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 08:52:12 -04:00
parent 5a878b78d4
commit f480a56737
13 changed files with 748 additions and 106 deletions
@@ -477,6 +477,53 @@ public static class InstanceCommands
});
group.Add(clearCmd);
// import (CSV bulk apply — parity with `instance import-overrides`)
var importIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var importFileOption = new Option<string>("--file")
{
Description = "Path to the override CSV file (columns: SourceName,Connection,SourceReference,Filter; blank field = inherited)",
Required = true
};
var importCmd = new Command("import")
{
Description = "Apply native alarm source overrides from a CSV file (all-or-nothing)"
};
importCmd.Add(importIdOption);
importCmd.Add(importFileOption);
importCmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(importIdOption);
var filePath = result.GetValue(importFileOption)!;
string csvText;
try
{
csvText = File.ReadAllText(filePath);
}
catch (Exception ex)
{
OutputFormatter.WriteError($"Cannot read file '{filePath}': {ex.Message}", "FILE_READ_ERROR");
return 1;
}
var parseResult = NativeAlarmSourceOverrideCsvParser.Parse(csvText);
if (parseResult.Errors.Count > 0)
{
foreach (var error in parseResult.Errors)
OutputFormatter.WriteError(error, "INVALID_CSV");
return 1;
}
var overrides = parseResult.Rows
.Select(r => new NativeAlarmSourceOverrideEntry(
r.SourceName, r.Connection, r.SourceReference, r.Filter))
.ToList();
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetInstanceNativeAlarmSourceOverridesCommand(id, overrides));
});
group.Add(importCmd);
return group;
}
+25
View File
@@ -503,6 +503,31 @@ Clear an instance's native alarm source override, reverting to the inherited bin
scadabridge --url <url> instance native-alarm-source clear --instance-id <int> --source <string>
```
#### `instance native-alarm-source import`
Bulk-apply native alarm source overrides for a single instance from a CSV file
(all-or-nothing — the native-source parity of `instance import-overrides`). The CSV
header is required: `SourceName,Connection,SourceReference,Filter`; a blank override
field keeps the inherited value. Every named source must resolve for the instance and
be unlocked, or the whole batch is rejected before any write.
```sh
scadabridge --url <url> instance native-alarm-source import --instance-id <int> --file <path>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--instance-id` | yes | Instance ID |
| `--file` | yes | Path to the override CSV (`SourceName,Connection,SourceReference,Filter`; blank field = inherited) |
Example CSV:
```csv
SourceName,Connection,SourceReference,Filter
Pressure,OpcB,ns=2;s=Pump.Alarm,Active
Module.Temp,,,
```
#### `instance deploy`
Deploy an instance to its site. Acquires the per-instance operation lock.
@@ -62,3 +62,26 @@ public record DeleteInstanceNativeAlarmSourceOverrideCommand(
string SourceCanonicalName);
public record ListInstanceNativeAlarmSourceOverridesCommand(int InstanceId);
/// <summary>
/// One entry in a bulk native-alarm-source-override apply: the source binding's
/// canonical name plus the three optional retarget fields (null keeps the inherited
/// value). Mirrors <see cref="SetInstanceNativeAlarmSourceOverrideCommand"/>'s fields
/// for a single source.
/// </summary>
public record NativeAlarmSourceOverrideEntry(
string SourceCanonicalName,
string? ConnectionNameOverride,
string? SourceReferenceOverride,
string? ConditionFilterOverride);
/// <summary>
/// Bulk, all-or-nothing apply of per-instance native-alarm-source overrides — the
/// native-source parity analogue of <see cref="SetInstanceOverridesCommand"/>. Backs
/// the CLI <c>instance native-alarm-source import --file</c> CSV path. Every entry's
/// source must resolve for the instance and be unlocked; if any entry is invalid the
/// whole batch is rejected before any write.
/// </summary>
public record SetInstanceNativeAlarmSourceOverridesCommand(
int InstanceId,
IReadOnlyList<NativeAlarmSourceOverrideEntry> Overrides);
@@ -0,0 +1,113 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
/// <summary>
/// Shared, dependency-free RFC-4180-ish splitter for a single physical CSV line.
/// Extracted so the instance-override CSV parsers (<see cref="OverrideCsvParser"/>
/// and <see cref="NativeAlarmSourceOverrideCsvParser"/>) apply identical quoting
/// rules without duplicating the subtle field-state machine.
/// </summary>
/// <remarks>
/// Quoting rules:
/// <list type="bullet">
/// <item>A field is <i>quoted</i> only if its first non-whitespace char is a
/// <c>"</c> (leading whitespace before the opening quote is allowed and ignored).
/// Inside a quoted field, commas are literal and <c>""</c> is an escaped single
/// quote; the closing <c>"</c> must be the last non-whitespace char of the field
/// (trailing whitespace after the close is allowed and ignored).</item>
/// <item>A <c>"</c> appearing anywhere else in an unquoted field (i.e. after
/// non-whitespace content) is a <b>literal</b> character and is preserved.</item>
/// <item>Unquoted fields are whitespace-trimmed; quoted field values are kept
/// verbatim.</item>
/// </list>
/// </remarks>
public static class CsvLineSplitter
{
/// <summary>
/// Splits a single physical CSV line into its fields, applying the quoting rules
/// described on <see cref="CsvLineSplitter"/>. Returns <c>true</c> with the split
/// <paramref name="fields"/> on success; returns <c>false</c> when a quoted field
/// is opened but never closed before end-of-line (the caller emits a per-line
/// "unterminated" error).
/// </summary>
/// <param name="line">The physical line to split (no trailing newline).</param>
/// <param name="fields">The split fields on success; an empty list on failure.</param>
/// <returns><c>true</c> when the line split cleanly; <c>false</c> on an unterminated quoted field.</returns>
public static bool TrySplit(string line, out List<string> fields)
{
fields = new List<string>();
var field = new System.Text.StringBuilder();
var inQuotes = false; // currently between an opening and closing quote
var quoted = false; // this field opened with a quote → keep value verbatim
var sawContent = false; // any non-whitespace char seen in the current field yet
for (var i = 0; i < line.Length; i++)
{
var c = line[i];
if (inQuotes)
{
if (c == '"')
{
// Doubled quote inside a quoted field → a single literal quote.
if (i + 1 < line.Length && line[i + 1] == '"')
{
field.Append('"');
i++;
}
else
{
inQuotes = false; // closing quote; only trailing whitespace may follow
}
}
else
{
field.Append(c);
}
continue;
}
switch (c)
{
case ',':
fields.Add(Finalize(field, quoted));
field.Clear();
inQuotes = false;
quoted = false;
sawContent = false;
break;
case '"' when !sawContent:
// Opening quote: first non-whitespace char of the field. Any
// leading whitespace seen so far is part of the (ignored) prefix.
field.Clear();
inQuotes = true;
quoted = true;
sawContent = true;
break;
default:
// After a quoted field has closed, only whitespace may appear
// before the next delimiter — it is ignored, not appended.
if (quoted)
break;
// A '"' here (sawContent already true) falls through as a literal.
if (!char.IsWhiteSpace(c))
sawContent = true;
field.Append(c);
break;
}
}
if (inQuotes)
return false; // opened a quoted field that was never closed
fields.Add(Finalize(field, quoted));
return true;
}
private static string Finalize(System.Text.StringBuilder field, bool quoted)
{
var text = field.ToString();
return quoted ? text : text.Trim(); // only unquoted whitespace is trimmed
}
}
@@ -0,0 +1,125 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
/// <summary>
/// One parsed per-instance native-alarm-source-override CSV row: the source
/// binding's canonical name plus the three optional retarget fields. A blank
/// (null) <see cref="Connection"/>/<see cref="SourceReference"/>/<see cref="Filter"/>
/// means "keep the inherited value" — the same blank-is-inherited semantics as the
/// CLI <c>instance native-alarm-source set</c> command. <see cref="LineNumber"/> is
/// the 1-based source line (the header is line 1) so downstream errors can point
/// back at the operator's file.
/// </summary>
public sealed record NativeAlarmSourceOverrideCsvRow(
string SourceName, string? Connection, string? SourceReference, string? Filter, int LineNumber);
/// <summary>
/// Outcome of parsing a native-alarm-source-override CSV: the successfully-parsed
/// <see cref="Rows"/> plus per-line <see cref="Errors"/>. Parsing never throws —
/// malformed rows are reported and excluded, valid rows still flow through.
/// Downstream callers validate source names against the flattened instance; this
/// parser is purely syntactic.
/// </summary>
public sealed record NativeAlarmSourceOverrideCsvParseResult(
IReadOnlyList<NativeAlarmSourceOverrideCsvRow> Rows, IReadOnlyList<string> Errors);
/// <summary>
/// Pure, dependency-free, quote-aware parser turning per-instance
/// native-alarm-source-override CSV text into structured rows plus per-line errors.
/// Callers supply the text (no file I/O). The header row is required and
/// case-insensitive (<c>SourceName,Connection,SourceReference,Filter</c>). Fields
/// follow the shared <see cref="CsvLineSplitter"/> RFC-4180 quoting rules — the same
/// splitter <see cref="OverrideCsvParser"/> uses. The parity analogue of
/// <see cref="OverrideCsvParser"/> for native alarm source retargets.
/// </summary>
public static class NativeAlarmSourceOverrideCsvParser
{
private const string HeaderError =
"Missing or invalid header row. Expected 'SourceName,Connection,SourceReference,Filter'.";
private const int ExpectedColumns = 4;
/// <summary>
/// Parses native-alarm-source-override CSV <paramref name="csvText"/>. Returns
/// parsed rows and any per-line errors; never throws. On a missing/unrecognized
/// header returns zero rows and a single header error.
/// </summary>
/// <param name="csvText">The raw CSV text to parse.</param>
/// <returns>The parsed rows and any per-line errors.</returns>
public static NativeAlarmSourceOverrideCsvParseResult Parse(string csvText)
{
var rows = new List<NativeAlarmSourceOverrideCsvRow>();
var errors = new List<string>();
// Split into physical lines; \r\n and \r are normalized to \n boundaries.
var lines = (csvText ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var headerSeen = false;
for (var i = 0; i < lines.Length; i++)
{
var lineNumber = i + 1;
var rawLine = lines[i];
// Skip fully-blank lines (whitespace-only included) without error.
if (string.IsNullOrWhiteSpace(rawLine))
continue;
if (!CsvLineSplitter.TrySplit(rawLine, out var fields))
{
errors.Add($"Line {lineNumber}: Unterminated quoted field.");
continue;
}
if (!headerSeen)
{
if (!TryMatchHeader(fields))
{
errors.Add(HeaderError);
return new NativeAlarmSourceOverrideCsvParseResult(rows, errors);
}
headerSeen = true;
continue;
}
if (fields.Count != ExpectedColumns)
{
errors.Add(
$"Line {lineNumber}: expected {ExpectedColumns} columns but found {fields.Count}.");
continue;
}
var sourceName = fields[0];
if (string.IsNullOrWhiteSpace(sourceName))
{
errors.Add($"Line {lineNumber}: SourceName must not be blank.");
continue;
}
rows.Add(new NativeAlarmSourceOverrideCsvRow(
sourceName,
NullIfEmpty(fields[1]),
NullIfEmpty(fields[2]),
NullIfEmpty(fields[3]),
lineNumber));
}
if (!headerSeen)
errors.Add(HeaderError);
return new NativeAlarmSourceOverrideCsvParseResult(rows, errors);
}
/// <summary>Matches the required 4-column header (case-insensitive).</summary>
private static bool TryMatchHeader(IReadOnlyList<string> fields) =>
fields.Count == ExpectedColumns &&
HeaderEquals(fields[0], "SourceName") &&
HeaderEquals(fields[1], "Connection") &&
HeaderEquals(fields[2], "SourceReference") &&
HeaderEquals(fields[3], "Filter");
private static bool HeaderEquals(string field, string expected) =>
string.Equals(field, expected, StringComparison.OrdinalIgnoreCase);
private static string? NullIfEmpty(string field) => field.Length == 0 ? null : field;
}
@@ -60,7 +60,7 @@ public static class OverrideCsvParser
if (string.IsNullOrWhiteSpace(rawLine))
continue;
if (!SplitFields(rawLine, out var fields))
if (!CsvLineSplitter.TrySplit(rawLine, out var fields))
{
errors.Add($"Line {lineNumber}: Unterminated quoted field.");
continue;
@@ -134,101 +134,4 @@ public static class OverrideCsvParser
string.Equals(field, expected, StringComparison.OrdinalIgnoreCase);
private static string? NullIfEmpty(string field) => field.Length == 0 ? null : field;
/// <summary>
/// RFC-4180-ish field splitter for a single physical line. Quoting rules:
/// <list type="bullet">
/// <item>A field is <i>quoted</i> only if its first non-whitespace char is a
/// <c>"</c> (leading whitespace before the opening quote is allowed and
/// ignored). Inside a quoted field, commas are literal and <c>""</c> is an
/// escaped single quote; the closing <c>"</c> must be the last non-whitespace
/// char of the field (trailing whitespace after the close is allowed and
/// ignored).</item>
/// <item>A <c>"</c> appearing anywhere else in an unquoted field (i.e. after
/// non-whitespace content) is a <b>literal</b> character and is preserved.</item>
/// <item>Unquoted fields are whitespace-trimmed; quoted field values are kept
/// verbatim.</item>
/// </list>
/// Returns <c>true</c> with the split <paramref name="fields"/> on success;
/// returns <c>false</c> when a quoted field is opened but never closed before
/// end-of-line (the caller emits a per-line "unterminated" error).
/// </summary>
private static bool SplitFields(string line, out List<string> fields)
{
fields = new List<string>();
var field = new System.Text.StringBuilder();
var inQuotes = false; // currently between an opening and closing quote
var quoted = false; // this field opened with a quote → keep value verbatim
var sawContent = false; // any non-whitespace char seen in the current field yet
for (var i = 0; i < line.Length; i++)
{
var c = line[i];
if (inQuotes)
{
if (c == '"')
{
// Doubled quote inside a quoted field → a single literal quote.
if (i + 1 < line.Length && line[i + 1] == '"')
{
field.Append('"');
i++;
}
else
{
inQuotes = false; // closing quote; only trailing whitespace may follow
}
}
else
{
field.Append(c);
}
continue;
}
switch (c)
{
case ',':
fields.Add(Finalize(field, quoted));
field.Clear();
inQuotes = false;
quoted = false;
sawContent = false;
break;
case '"' when !sawContent:
// Opening quote: first non-whitespace char of the field. Any
// leading whitespace seen so far is part of the (ignored) prefix.
field.Clear();
inQuotes = true;
quoted = true;
sawContent = true;
break;
default:
// After a quoted field has closed, only whitespace may appear
// before the next delimiter — it is ignored, not appended.
if (quoted)
break;
// A '"' here (sawContent already true) falls through as a literal.
if (!char.IsWhiteSpace(c))
sawContent = true;
field.Append(c);
break;
}
}
if (inQuotes)
return false; // opened a quoted field that was never closed
fields.Add(Finalize(field, quoted));
return true;
}
private static string Finalize(System.Text.StringBuilder field, bool quoted)
{
var text = field.ToString();
return quoted ? text : text.Trim(); // only unquoted whitespace is trimmed
}
}
@@ -270,6 +270,7 @@ public class ManagementActor : ReceiveActor
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
or SetInstanceNativeAlarmSourceOverridesCommand
or GetDeploymentDiffCommand
or MgmtDeployArtifactsCommand
or QueryDeploymentsCommand
@@ -347,6 +348,7 @@ public class ManagementActor : ReceiveActor
DeleteInstanceAlarmOverrideCommand cmd => await HandleDeleteInstanceAlarmOverride(sp, cmd, user),
ListInstanceAlarmOverridesCommand cmd => await HandleListInstanceAlarmOverrides(sp, cmd, user),
SetInstanceNativeAlarmSourceOverrideCommand cmd => await HandleSetInstanceNativeAlarmSourceOverride(sp, cmd, user),
SetInstanceNativeAlarmSourceOverridesCommand cmd => await HandleSetInstanceNativeAlarmSourceOverrides(sp, cmd, user),
DeleteInstanceNativeAlarmSourceOverrideCommand cmd => await HandleDeleteInstanceNativeAlarmSourceOverride(sp, cmd, user),
ListInstanceNativeAlarmSourceOverridesCommand cmd => await HandleListInstanceNativeAlarmSourceOverrides(sp, cmd, user),
@@ -998,6 +1000,78 @@ public class ManagementActor : ReceiveActor
return existing;
}
private static async Task<object?> HandleSetInstanceNativeAlarmSourceOverrides(
IServiceProvider sp, SetInstanceNativeAlarmSourceOverridesCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
// All-or-nothing (parity with HandleSetInstanceOverrides). Flatten the
// instance ONCE (script compilation skipped — a lock/resolve check does not
// need it) and validate EVERY requested source up front: a source that does
// not resolve would create a dead override the flattener silently drops, and a
// template-locked source cannot be overridden. Only once the whole batch is
// known valid do we upsert — a mid-batch reject never leaves partial state.
var pipeline = sp.GetRequiredService<IFlatteningPipeline>();
var flattenResult = await pipeline.FlattenAndValidateAsync(
cmd.InstanceId, default, validateScripts: false);
if (flattenResult.IsFailure)
throw new ManagementCommandException(
$"Cannot set native alarm source overrides: the instance could not be flattened ({flattenResult.Error}).");
var resolvedByName = flattenResult.Value.Configuration.NativeAlarmSources
.ToDictionary(s => s.CanonicalName, StringComparer.Ordinal);
// Reject a batch that names the same source twice — the intended override
// would be ambiguous, and the last-writer-wins upsert would silently discard
// the earlier row.
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in cmd.Overrides)
{
if (!resolvedByName.TryGetValue(entry.SourceCanonicalName, out var resolvedSource))
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' does not resolve for this instance " +
"and cannot be overridden. No overrides were applied.");
if (resolvedSource.IsLocked)
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' is locked at the template level and " +
"cannot be overridden. No overrides were applied.");
if (!seen.Add(entry.SourceCanonicalName))
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' appears more than once in the batch. " +
"No overrides were applied.");
}
var results = new List<InstanceNativeAlarmSourceOverride>();
foreach (var entry in cmd.Overrides)
{
var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, entry.SourceCanonicalName);
if (existing == null)
{
var ovr = new InstanceNativeAlarmSourceOverride(entry.SourceCanonicalName)
{
InstanceId = cmd.InstanceId,
ConnectionNameOverride = entry.ConnectionNameOverride,
SourceReferenceOverride = entry.SourceReferenceOverride,
ConditionFilterOverride = entry.ConditionFilterOverride
};
await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
results.Add(ovr);
}
else
{
existing.ConnectionNameOverride = entry.ConnectionNameOverride;
existing.SourceReferenceOverride = entry.SourceReferenceOverride;
existing.ConditionFilterOverride = entry.ConditionFilterOverride;
await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
results.Add(existing);
}
}
await repo.SaveChangesAsync();
return results;
}
private static async Task<object?> HandleDeleteInstanceNativeAlarmSourceOverride(
IServiceProvider sp, DeleteInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
{
@@ -403,7 +403,9 @@ public class ScriptRuntimeContext
/// <para>
/// <b>Quality-agnostic by default (spec §4.2):</b> a value arriving at Bad
/// quality still satisfies the wait — the match tests the value, not the quality.
/// A quality-gated ("Good"-only) mode is a planned enhancement, deferred per spec §4.2.
/// Opt into a quality-gated ("Good"-only) wait via the <c>requireGoodQuality</c>
/// argument (spec §4.2); the InstanceActor then holds the wait until the value
/// satisfies the test at Good quality (or times out).
/// </para>
///
/// <para>