feat(#23): elevate connection-binding completeness to a deploy-gating Error (M2.8)

Pre-deployment validation only WARNED when a data-sourced attribute had no
connection binding, so an instance with unresolved bindings still passed IsValid
and could deploy. There was also no check that a binding resolves to a connection
that actually exists at the target site.

- ValidationService.Validate gains an opt-in `enforceConnectionBindings` flag
  (default false) plus a `siteConnectionNames` set. Default-false keeps the
  template DESIGN-TIME path (ManagementActor.HandleValidateTemplate) non-blocking,
  since bindings are legitimately set later at instance/deploy time. The DEPLOY
  path (FlatteningPipeline) opts in (true) so:
    * a data-sourced attribute with no binding is now a deploy-gating Error;
    * a binding to a connection that does not exist on the target site is an Error.
  Static (non-data-sourced) attributes are never flagged.
- FlatteningPipeline computes the site-connection-names set from the loaded site
  data connections (mirroring M2.1's alarmCapableConnectionNames) and threads it in.
- Tests: TemplateEngine.Tests covers design-time warning / deploy-time error /
  static-ok / exists-at-site / non-existent-connection. New
  FlatteningPipelineConnectionBindingTests proves the deploy path enforces it.

Mark M2.7 + M2.8 completed in the plan task tracker.
This commit is contained in:
Joseph Doherty
2026-06-16 05:27:58 -04:00
parent a8e9e9952d
commit 7c14a69091
5 changed files with 330 additions and 14 deletions
@@ -14,7 +14,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// 4. Alarm trigger references exist (referenced attributes must be in the flattened config)
/// 5. Script trigger references exist (referenced attributes must be in the flattened config)
/// 6. Expression triggers — blank check, syntax check, and attribute-reference scan
/// 7. Connection binding completeness (all data-sourced attributes must have a binding)
/// 7. Connection binding completeness — every data-sourced attribute must have a binding,
/// and (on the deploy path) the bound connection must exist on the target site.
/// Severity is context-dependent: a non-blocking Warning at template design time
/// (bindings are set later) and a deploy-gating Error when enforced (M2.8 / #23).
/// 8. Does NOT verify tag path resolution on devices
/// </summary>
public class ValidationService
@@ -52,11 +55,37 @@ public class ValidationService
/// the semantic validator gates every native-alarm-source binding against it.
/// <c>null</c> skips the capability check (its absence makes the check inert).
/// </param>
/// <param name="enforceConnectionBindings">
/// M2.8 (#23): controls the severity of the connection-binding-completeness check.
/// <para>
/// <c>false</c> (default) — template DESIGN-TIME: a data-sourced attribute that is
/// not yet bound produces only a non-blocking <c>Warning</c>. Bindings are set later,
/// at instance/deploy time, so an unbound data-sourced template attribute is legitimate
/// here (see <see cref="ManagementService"/>'s ValidateTemplate path, which builds a
/// config straight from raw template members with no bindings).
/// </para>
/// <para>
/// <c>true</c> — DEPLOY path (<see cref="DeploymentManager"/>'s FlatteningPipeline):
/// an unbound data-sourced attribute becomes a deploy-gating <c>Error</c> (IsValid false),
/// and — when <paramref name="siteConnectionNames"/> is supplied — a binding pointing at a
/// connection that does not exist on the target site is also an <c>Error</c>.
/// </para>
/// </param>
/// <param name="siteConnectionNames">
/// M2.8 (#23): optional set of the data-connection names that actually exist on the
/// target site (computed by the deploy pipeline from the site's loaded connections,
/// mirroring <paramref name="alarmCapableConnectionNames"/>). When supplied (and
/// <paramref name="enforceConnectionBindings"/> is <c>true</c>), every bound
/// connection is checked against this set so a binding to a phantom/stale connection
/// is caught. <c>null</c> skips the "exists at site" half (it stays inert).
/// </param>
/// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
public ValidationResult Validate(
FlattenedConfiguration configuration,
IReadOnlyList<ResolvedScript>? sharedScripts = null,
IReadOnlySet<string>? alarmCapableConnectionNames = null)
IReadOnlySet<string>? alarmCapableConnectionNames = null,
bool enforceConnectionBindings = false,
IReadOnlySet<string>? siteConnectionNames = null)
{
ArgumentNullException.ThrowIfNull(configuration);
@@ -68,7 +97,7 @@ public class ValidationService
ValidateAlarmTriggerReferences(configuration),
ValidateScriptTriggerReferences(configuration),
ValidateExpressionTriggers(configuration),
ValidateConnectionBindingCompleteness(configuration),
ValidateConnectionBindingCompleteness(configuration, enforceConnectionBindings, siteConnectionNames),
_semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames)
};
@@ -507,21 +536,76 @@ public class ValidationService
}
/// <summary>
/// Validates that all data-sourced attributes have connection bindings.
/// Validates connection bindings on data-sourced attributes. Only DATA-SOURCED
/// attributes (<see cref="ResolvedAttribute.DataSourceReference"/> != <c>null</c>)
/// require a binding; static attributes are never flagged.
///
/// M2.8 (#23): the severity is context-dependent (see <paramref name="enforce"/>).
/// At template design time (<c>enforce == false</c>) an unbound data-sourced
/// attribute is legitimate (bindings are set later) so it is only a non-blocking
/// <c>Warning</c>. On the deploy path (<c>enforce == true</c>) an unbound
/// data-sourced attribute is a deploy-gating <c>Error</c>, and — when
/// <paramref name="siteConnectionNames"/> is supplied — a binding to a connection
/// that does not exist on the target site is also an <c>Error</c>.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <returns>A <see cref="ValidationResult"/> with warnings for each data-sourced attribute that lacks a connection binding.</returns>
public static ValidationResult ValidateConnectionBindingCompleteness(FlattenedConfiguration configuration)
/// <param name="enforce">
/// <c>true</c> on the deploy path (unbound → Error + "exists at site" check);
/// <c>false</c> at design time (unbound → Warning only). Defaults to <c>false</c>
/// so design-time validation stays non-blocking.
/// </param>
/// <param name="siteConnectionNames">
/// Optional set of data-connection names that actually exist on the target site.
/// When non-<c>null</c> and <paramref name="enforce"/> is <c>true</c>, every bound
/// connection name is checked against this set. <c>null</c> skips the "exists at
/// site" check.
/// </param>
/// <returns>A <see cref="ValidationResult"/> with the binding findings at the appropriate severity.</returns>
public static ValidationResult ValidateConnectionBindingCompleteness(
FlattenedConfiguration configuration,
bool enforce = false,
IReadOnlySet<string>? siteConnectionNames = null)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
foreach (var attr in configuration.Attributes)
{
if (attr.DataSourceReference != null && attr.BoundDataConnectionId == null)
// Only data-sourced attributes participate in binding validation.
if (attr.DataSourceReference == null)
continue;
if (attr.BoundDataConnectionId == null)
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding,
$"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
// Unbound data-sourced attribute. At deploy time this gates the
// deployment; at design time the binding is set later, so it is
// only advisory.
if (enforce)
{
errors.Add(ValidationEntry.Error(ValidationCategory.ConnectionBinding,
$"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
attr.CanonicalName));
}
else
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding,
$"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
attr.CanonicalName));
}
continue;
}
// The attribute IS bound. On the deploy path, verify the bound connection
// actually exists on the target site (resolve against the site's connection
// set, not just name presence in the config). A binding pointing at a
// non-existent/stale site connection is a deploy-gating Error.
if (enforce && siteConnectionNames != null &&
attr.BoundDataConnectionName != null &&
!siteConnectionNames.Contains(attr.BoundDataConnectionName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.ConnectionBinding,
$"Attribute '{attr.CanonicalName}' is bound to data connection '{attr.BoundDataConnectionName}' " +
"which does not exist on the target site.",
attr.CanonicalName));
}
}