854a6a8c0b
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
209 lines
10 KiB
C#
209 lines
10 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
|
|
|
/// <summary>
|
|
/// Orchestrates the TemplateEngine services (FlatteningService, ValidationService, RevisionHashService)
|
|
/// into a single pipeline for deployment use.
|
|
///
|
|
/// This captures template state at the time of flatten, ensuring that concurrent template edits
|
|
/// (last-write-wins) do not conflict with in-progress deployments.
|
|
/// </summary>
|
|
public class FlatteningPipeline : IFlatteningPipeline
|
|
{
|
|
private readonly ITemplateEngineRepository _templateRepo;
|
|
private readonly ISiteRepository _siteRepo;
|
|
private readonly FlatteningService _flatteningService;
|
|
private readonly ValidationService _validationService;
|
|
private readonly RevisionHashService _revisionHashService;
|
|
private readonly ISharedSchemaRepository _sharedSchemaRepo;
|
|
|
|
/// <summary>Initializes a new <see cref="FlatteningPipeline"/> with the required template engine and site repositories and services.</summary>
|
|
/// <param name="templateRepo">Repository for loading templates and instance data.</param>
|
|
/// <param name="siteRepo">Repository for loading site data used during validation.</param>
|
|
/// <param name="flatteningService">Service that flattens the template inheritance chain into a resolved config.</param>
|
|
/// <param name="validationService">Service that performs semantic validation on the flattened config.</param>
|
|
/// <param name="revisionHashService">Service that computes the revision hash for staleness detection.</param>
|
|
/// <param name="sharedSchemaRepo">
|
|
/// Repository backing the JSON-Schema <c>$ref</c> resolution seam. Used to
|
|
/// look up <c>lib:Name</c> library references so a dangling reference in any validated
|
|
/// script schema becomes a deploy-blocking error.
|
|
/// </param>
|
|
public FlatteningPipeline(
|
|
ITemplateEngineRepository templateRepo,
|
|
ISiteRepository siteRepo,
|
|
FlatteningService flatteningService,
|
|
ValidationService validationService,
|
|
RevisionHashService revisionHashService,
|
|
ISharedSchemaRepository sharedSchemaRepo)
|
|
{
|
|
_templateRepo = templateRepo;
|
|
_siteRepo = siteRepo;
|
|
_flatteningService = flatteningService;
|
|
_validationService = validationService;
|
|
_revisionHashService = revisionHashService;
|
|
_sharedSchemaRepo = sharedSchemaRepo;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
|
|
int instanceId,
|
|
CancellationToken cancellationToken = default,
|
|
bool validateScripts = true)
|
|
{
|
|
// Load instance with full graph
|
|
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
|
|
if (instance == null)
|
|
return Result<FlatteningPipelineResult>.Failure($"Instance with ID {instanceId} not found.");
|
|
|
|
// Build template chain
|
|
var templateChain = await BuildTemplateChainAsync(instance.TemplateId, cancellationToken);
|
|
if (templateChain.Count == 0)
|
|
return Result<FlatteningPipelineResult>.Failure("Template chain is empty.");
|
|
|
|
// Build composition maps, walking nested compositions so the flattener
|
|
// can resolve composed-of-composed attributes / alarms / scripts (e.g.
|
|
// a parent that composes Pump where Pump itself composes AlarmSensor
|
|
// produces "Pump.AlarmSensor.SensorReading").
|
|
var compositionMap = new Dictionary<int, IReadOnlyList<Commons.Entities.Templates.TemplateComposition>>();
|
|
var composedChains = new Dictionary<int, IReadOnlyList<Commons.Entities.Templates.Template>>();
|
|
var processedTemplateIds = new HashSet<int>();
|
|
var pendingChains = new Queue<IReadOnlyList<Commons.Entities.Templates.Template>>();
|
|
|
|
pendingChains.Enqueue(templateChain);
|
|
while (pendingChains.Count > 0)
|
|
{
|
|
var chain = pendingChains.Dequeue();
|
|
foreach (var template in chain)
|
|
{
|
|
if (!processedTemplateIds.Add(template.Id)) continue;
|
|
|
|
var compositions = await _templateRepo.GetCompositionsByTemplateIdAsync(template.Id, cancellationToken);
|
|
if (compositions.Count == 0) continue;
|
|
|
|
compositionMap[template.Id] = compositions;
|
|
foreach (var comp in compositions)
|
|
{
|
|
if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue;
|
|
|
|
var composedChain = await BuildTemplateChainAsync(comp.ComposedTemplateId, cancellationToken);
|
|
composedChains[comp.ComposedTemplateId] = composedChain;
|
|
pendingChains.Enqueue(composedChain);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load data connections for the site
|
|
var dataConnections = await LoadDataConnectionsAsync(instance.SiteId, cancellationToken);
|
|
|
|
// Flatten
|
|
var flattenResult = _flatteningService.Flatten(
|
|
instance, templateChain, compositionMap, composedChains, dataConnections);
|
|
|
|
if (flattenResult.IsFailure)
|
|
return Result<FlatteningPipelineResult>.Failure(flattenResult.Error);
|
|
|
|
var config = flattenResult.Value;
|
|
|
|
// Load shared scripts for semantic validation
|
|
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
|
|
var resolvedSharedScripts = sharedScriptEntities.Select(s => new ResolvedScript
|
|
{
|
|
CanonicalName = s.Name,
|
|
Code = s.Code,
|
|
ParameterDefinitions = s.ParameterDefinitions,
|
|
ReturnDefinition = s.ReturnDefinition
|
|
}).ToList();
|
|
|
|
// Compute the alarm-capable connection-name set so the semantic validator
|
|
// can gate native-alarm-source bindings. "Alarm-capable" matches the DCL
|
|
// runtime decision (DataConnectionActor: _adapter is IAlarmSubscribableConnection);
|
|
// here we filter connections by alarm-capable protocol, then collect their names.
|
|
//
|
|
// StringComparer.Ordinal is intentional: connection names are stored and
|
|
// matched as authored throughout the pipeline (all other name-keyed
|
|
// dictionaries in FlatteningService and SemanticValidator use the same
|
|
// case-sensitive semantics). OrdinalIgnoreCase would be inconsistent with
|
|
// the rest of the binding-resolution path.
|
|
var alarmCapableConnectionNames = dataConnections.Values
|
|
.Where(c => AlarmCapableProtocols.IsAlarmCapable(c.Protocol))
|
|
.Select(c => c.Name)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
// The set of data-connection names that actually exist on the
|
|
// target site, used to verify each bound connection resolves to a real site
|
|
// connection. Same StringComparer.Ordinal as the rest of the binding-resolution
|
|
// path (connection names are matched as-authored throughout the pipeline).
|
|
var siteConnectionNames = dataConnections.Values
|
|
.Select(c => c.Name)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
// Build the JSON-Schema $ref resolution seam from the shared-schema
|
|
// library. The seam ValidationService consumes is synchronous, so the library is
|
|
// pre-loaded once into a name→JSON map here (avoiding sync-over-async) and the
|
|
// seam is a pure in-memory lookup. An unresolved {"$ref":"lib:Name"} in any
|
|
// validated script schema then becomes a deploy-blocking SchemaReference error.
|
|
var sharedSchemas = await _sharedSchemaRepo.ListAsync(cancellationToken);
|
|
var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
|
|
Func<string, string?> resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);
|
|
|
|
// Validate. This is the deploy-gating path, so connection-binding completeness
|
|
// is enforced as an Error (enforceConnectionBindings: true): a data-sourced
|
|
// attribute with no binding — or one bound to a connection that no longer exists
|
|
// on the site — blocks the deployment. (The template DESIGN-TIME validate path in
|
|
// ManagementActor leaves this non-blocking by NOT enforcing, since bindings are
|
|
// set later at instance/deploy time.)
|
|
// validateScripts == false is the read-only staleness/comparison path: skip
|
|
// only the expensive Roslyn ValidateScriptCompilation (which loads a
|
|
// non-collectible InteractiveAssemblyLoader assembly per script). Structural
|
|
// and semantic validation still run so a genuinely broken flatten is surfaced;
|
|
// the deploy path (validateScripts: true) remains the authoritative compile gate.
|
|
var validation = _validationService.Validate(
|
|
config,
|
|
resolvedSharedScripts,
|
|
alarmCapableConnectionNames,
|
|
enforceConnectionBindings: true,
|
|
siteConnectionNames: siteConnectionNames,
|
|
resolveSchemaRef: resolveSchemaRef,
|
|
validateScriptCompilation: validateScripts);
|
|
|
|
// Compute revision hash
|
|
var hash = _revisionHashService.ComputeHash(config);
|
|
|
|
return Result<FlatteningPipelineResult>.Success(
|
|
new FlatteningPipelineResult(config, hash, validation));
|
|
}
|
|
|
|
private async Task<IReadOnlyList<Commons.Entities.Templates.Template>> BuildTemplateChainAsync(
|
|
int templateId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var chain = new List<Commons.Entities.Templates.Template>();
|
|
var currentId = (int?)templateId;
|
|
|
|
while (currentId.HasValue)
|
|
{
|
|
var template = await _templateRepo.GetTemplateWithChildrenAsync(currentId.Value, cancellationToken);
|
|
if (template == null) break;
|
|
chain.Add(template);
|
|
currentId = template.ParentTemplateId;
|
|
}
|
|
|
|
return chain;
|
|
}
|
|
|
|
private async Task<IReadOnlyDictionary<int, DataConnection>> LoadDataConnectionsAsync(
|
|
int siteId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var connections = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, cancellationToken);
|
|
return connections.ToDictionary(c => c.Id);
|
|
}
|
|
}
|