Compare commits

...

23 Commits

Author SHA1 Message Date
Joseph Doherty 3782ebdadb fix(transport): skip unbound (empty ConnectionName) bindings on import instead of writing FK=0 (M8 INT review) 2026-06-18 07:56:00 -04:00
Joseph Doherty 4d888c63a3 docs(m8): Transport site/instance transport, name-mapping, Myers diff, stale enumeration (M8 INT) 2026-06-18 07:53:25 -04:00
Joseph Doherty f881521cc9 fix(transport-ui): reset connection choices on site change + clear state on back (M8 E2 review)
Fix 1: OnSiteChoiceChangedAsync now resets _connectionChoices for every
RequiredConnectionMapping under the changed source site after loading the new
target's connections. Choices are re-seeded to the same-named connection on
the new target if present, or CreateNewValue otherwise — preventing BuildNameMap
from emitting MapToExisting for a connection absent from the newly-chosen target.

Fix 2: BackToUpload now calls ResetSessionState() before resetting _step so
_session, _preview, _resolutions, _siteChoices, _connectionChoices,
_targetSites, and _targetConnections are all cleared when the operator backs
out to re-upload, making it safe to start a new import flow from a clean slate.

Tests 12 + 13 added to TransportImportPageTests.
2026-06-18 07:39:19 -04:00
Joseph Doherty d45a7a5760 fix(transport): real stale-instance enumeration in ImportResult (M8 D2, #16) + native-alarm rename-redirect test 2026-06-18 07:35:08 -04:00
Joseph Doherty c8211f6363 feat(transport-ui): import Map step + per-line diff view (M8 E2) 2026-06-18 07:21:23 -04:00
Joseph Doherty e67587ec93 fix(transport-ui): use DataConnection ctor in CountSecrets tests (M8 build fix) 2026-06-18 07:11:41 -04:00
Joseph Doherty 565d53d0fe feat(transport): import name-map plumbing via CLI + ManagementActor (M8 D3) 2026-06-18 07:08:33 -04:00
Joseph Doherty d974477e87 fix(transport): connection map Pass-2 (FK) + site-qualified connection resolution (M8 D1-FIX, C1+C2) 2026-06-18 07:08:31 -04:00
Joseph Doherty 6457f03fae feat(transport): apply site/instance import with name-map + FK rewire (M8 D1, T18) 2026-06-18 06:52:40 -04:00
Joseph Doherty 542a3e92eb fix(transport-ui): count site data-connection config in export secret banner (M8, C2-SECRET-1) 2026-06-18 06:37:38 -04:00
Joseph Doherty 50d77b07cf feat(transport): preview diff + required-mapping detection + blockers (M8 C2) 2026-06-18 06:28:47 -04:00
Joseph Doherty d0b38ad726 feat(transport-ui): export wizard site/instance selection (M8 E1) 2026-06-18 06:26:36 -04:00
Joseph Doherty bdbf5cdab0 feat(transport): export site/instance selection end-to-end via CLI + ManagementActor (M8 B4) 2026-06-18 06:14:39 -04:00
Joseph Doherty d7dae24355 feat(transport): per-line Myers code diff + site/connection/instance compare (M8 C1, T20) 2026-06-18 06:07:04 -04:00
Joseph Doherty 7e5b1b0275 feat(transport): resolve site/instance export selection + deps (M8 B1) 2026-06-18 05:55:56 -04:00
Joseph Doherty bb6e883758 feat(transport): serialize site/connection/instance entities<->DTOs (M8 B2) 2026-06-18 05:54:17 -04:00
Joseph Doherty aefdb9f4b6 feat(transport): manifest summary counts + schemaVersion 1.1 (M8 B3) 2026-06-18 05:52:12 -04:00
Joseph Doherty 0e507052a1 feat(transport): site/connection/instance bundle DTOs (M8 A2) 2026-06-18 05:42:24 -04:00
Joseph Doherty c1282e06a2 feat(transport): pure Myers LineDiffer helper (M8 A3, T20) 2026-06-18 05:41:10 -04:00
Joseph Doherty 4f1925870f feat(transport): name-map types + preview/selection/summary extensions (M8 A1) 2026-06-18 05:40:24 -04:00
Joseph Doherty 2f3c1a4e64 chore(m8): task persistence for Transport plan (15 tasks, dependency DAG) 2026-06-18 05:31:01 -04:00
Joseph Doherty 1ee75ac620 docs(m8): Transport T18/T20/#16 implementation plan (15 tasks, waves A-E + INT) 2026-06-18 05:29:39 -04:00
Joseph Doherty e4964f7b30 docs(m8): approved Transport T18/T20 + #16 design (site/instance transport, name-mapping, Myers diff) 2026-06-18 05:25:04 -04:00
58 changed files with 9116 additions and 111 deletions
+2 -1
View File
@@ -88,7 +88,7 @@ Related repos cloned as sibling directories under `~/Desktop/` — referenced fo
21. Notification Outbox — Central component ingesting store-and-forwarded notifications, `Notifications` audit table, dispatcher loop, retry/parking, delivery KPIs.
22. Site Call Audit — Central component auditing site cached calls (`CachedCall`/`CachedWrite`); `SiteCalls` audit table, telemetry ingest, reconciliation, KPIs, central→site Retry/Discard relay; sites remain the source of truth.
23. Audit Log — Central append-only AuditLog table spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site SQLite hot-path + gRPC telemetry + reconciliation; combined telemetry with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API; monthly partitioning, 365-day retention.
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates, system artifacts, central-only configuration. Per-conflict resolution. Correlated audit via `BundleImportId`. No site involvement.
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates, system artifacts, central-only configuration, plus site/instance-scoped config (`Site`s, site `DataConnection`s, `Instance`s) reconciled across environments by a `BundleNameMap` name-mapping subsystem. Per-conflict resolution with a per-line Myers diff. Correlated audit via `BundleImportId`. Never touches site runtime nodes (imported instances land `NotDeployed`).
25. Script Analysis — Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI.
26. KPI History — Reusable central KPI-history backbone: tall/EAV `KpiSample` store in central MS SQL, `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s every minute, bucketed query (`GetRawSeriesAsync` + `KpiSeriesBucketer`) + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart`; ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health.
@@ -141,6 +141,7 @@ Related repos cloned as sibling directories under `~/Desktop/` — referenced fo
- Last-write-wins for concurrent template editing (no optimistic concurrency on templates).
- Optimistic concurrency on deployment status records.
- Naming collisions in composed feature modules are design-time errors.
- Transport (#24, M8): bundles now move site/instance config (`Site`s, site-scoped `DataConnection`s, `Instance`s + override children + `Area` by name), not just central-only config — but still never touch site runtime nodes (imported instances land `NotDeployed`). A `BundleNameMap` name-mapping subsystem (Commons: `SiteMapping`/`ConnectionMapping`/`MappingAction`) reconciles environment-specific identifiers (sites by `SiteIdentifier`, connections by site-qualified `{SiteIdentifier}/{Name}`, instances by `UniqueName`): auto-match → operator override via the import-wizard Map step / CLI `--map-site`/`--map-connection`/`--create-missing-*` → blocker rows for the unresolved. D3 "carry full config (encrypted secrets)": Site addresses travel; `DataConnection` config rides the encrypted `SecretsBlock` (presence-only in diffs). Per-line Myers diff for code fields via the pure `LineDiffer` (`ArtifactDiff` embeds a size-capped structured `lineDiff`); `ImportResult.StaleInstanceIds` is real via the `IStaleInstanceProbe` seam (Commons; implemented in DeploymentManager). `schemaVersion` 1.0→1.1 (additive); `bundleFormatVersion` stays 1; no new EF tables/columns.
### Store-and-Forward
- Fixed retry interval, no max buffer size. Only transient failures buffered.
+1 -1
View File
@@ -99,7 +99,7 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
| 21 | Notification Outbox | [docs/requirements/Component-NotificationOutbox.md](docs/requirements/Component-NotificationOutbox.md) | Central component ingesting store-and-forwarded notifications into the `Notifications` audit table, with `NotificationOutboxActor` singleton dispatcher, per-type delivery adapters, retry/parking, status tracking, daily purge, and delivery KPIs. |
| 22 | Site Call Audit | [docs/requirements/Component-SiteCallAudit.md](docs/requirements/Component-SiteCallAudit.md) | Central component auditing site cached calls (`ExternalSystem.CachedCall`/`Database.CachedWrite`) into the `SiteCalls` audit table, with `SiteCallAuditActor` singleton, telemetry ingest, periodic reconciliation, point-in-time KPIs, daily purge, and central→site Retry/Discard relay for parked calls. |
| 23 | Audit Log | [docs/requirements/Component-AuditLog.md](docs/requirements/Component-AuditLog.md) | New central append-only AuditLog spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site-local SQLite hot-path append + gRPC telemetry + central reconciliation; combined telemetry packet with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API middleware; monthly partitioning, 365-day default retention. |
| 24 | Transport | [docs/requirements/Component-Transport.md](docs/requirements/Component-Transport.md) | Bundle export/import for templates, shared scripts, external systems, central-only artifacts. AES-256-GCM encryption; per-conflict resolution on import; correlated audit trail. |
| 24 | Transport | [docs/requirements/Component-Transport.md](docs/requirements/Component-Transport.md) | Bundle export/import for templates, shared scripts, external systems, central-only artifacts, plus site-scoped config — `Site`s, site `DataConnection`s, and `Instance`s — reconciled across environments by a `BundleNameMap` name-mapping subsystem (auto-match + Map wizard step / CLI flags). AES-256-GCM encryption (carry-full-config with encrypted connection secrets); per-conflict resolution with a per-line Myers diff for code fields; real stale-instance enumeration; correlated audit trail. Never touches site runtime nodes. |
| 25 | Script Analysis | [docs/requirements/Component-ScriptAnalysis.md](docs/requirements/Component-ScriptAnalysis.md) | Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI. |
| 26 | KPI History | [docs/requirements/Component-KpiHistory.md](docs/requirements/Component-KpiHistory.md) | Reusable central KPI-history backbone: tall/EAV `KpiSample` store (central MS SQL), `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s each minute, bucketed `GetRawSeriesAsync` + `KpiSeriesBucketer` query, and a reusable custom-SVG `KpiTrendChart`. Ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health. |
@@ -94,8 +94,13 @@ Delivered per `docs/plans/2026-06-18-m7-opcua-mxgateway-ux-design.md` (full scop
Small follow-ups logged (not blocking): stamp `SourceNode` on the `SecuredWrite` audit rows (currently NULL); an aggregated **live** alarm stream for the summary page (snapshot + poll today); central-persisted, auditable cert trust (site-local today).
#### M8 — Transport (T18, T20)
Site-scoped / instance-scoped artifact transport (name-mapping subsystem); per-line/Myers diff for Modified artifacts.
#### M8 — Transport (T18, T20) — **DELIVERED**
Delivered both features, with the silent gap #16 folded in:
- **T18** — site/instance-scoped artifact transport. Transport now also moves `Site` definitions, site-scoped `DataConnection`s (protocol connections), and `Instance`s (+ `InstanceAttributeOverride`/`InstanceAlarmOverride`/`InstanceNativeAlarmSourceOverride`/`InstanceConnectionBinding` children + `Area` by name). New **name-mapping subsystem** (`BundleNameMap`/`SiteMapping`/`ConnectionMapping`/`MappingAction` in Commons): auto-match (sites by `SiteIdentifier`, connections by the site-qualified `{SiteIdentifier}/{Name}`, instances by `UniqueName`), operator override via the Central UI import **Map step** (between Passphrase and Diff) or CLI `--map-site`/`--map-connection`/`--create-missing-sites`/`--create-missing-connections`; export gains `--sites`/`--instances` + a UI Sites & Instances picker. D3 **carry-full-config (encrypted secrets)**: Site addresses travel; `DataConnection` `PrimaryConfiguration`/`BackupConfiguration` ride the encrypted `SecretsBlock` (presence-only in diffs). Apply resolves/creates target sites + connections in one EF transaction, upserts instances forced `NotDeployed`, and rewires FKs (`InstanceConnectionBinding.DataConnectionId` via the connection map with a Pass-2; `NativeAlarmSourceOverride.ConnectionNameOverride` to the mapped target). `schemaVersion` 1.0→1.1 (additive); `bundleFormatVersion` stays 1; **no new EF tables/columns**.
- **T20** — per-line **Myers** diff. New pure `LineDiffer` (custom O(ND), no third-party lib) replaces the coarse `<N lines>` marker for code fields; `ArtifactDiff` embeds a size-capped structured `lineDiff` (hunks + `truncated` + add/remove totals); the import wizard renders Modified code fields via the shared `LineDiffView` component.
- **#16 folded in** — `ImportResult.StaleInstanceIds` is no longer a stub: for each Overwritten template the importer enumerates deployed target instances whose freshly-flattened revision hash drifts from `DeployedConfigSnapshot.RevisionHash` via the new `IStaleInstanceProbe` seam (Commons; implemented in DeploymentManager), so Confirm shows a real count and Result deep-links the filtered Deployments page.
Small follow-ups logged (not blocking): DeploymentManagerRepository hydration for the stale probe; large-bundle/perf hardening. **T19** (direct cluster-to-cluster pull / asymmetric bundle signing / differential bundles) remains **deferred** to its own brainstorm.
#### M9 — Templates & authoring (T22–T26, T28, T30–T32)
Template tree search/filter; folder drag-drop + sibling reorder + root context menu; move data connection between sites; connection live-status indicators; base-template versioning "update-derived" flow + multi-level inheritance; strict expression-trigger analysis kind; schema-driven value-entry forms + hover/completion + JSON Schema `$ref`/library; CLI Retry/Discard for cached calls; unified notifications+site-calls outbox page.
@@ -0,0 +1,189 @@
# Design: M8 — Transport (T18, T20)
**Date:** 2026-06-18
**Status:** Approved (brainstorming session) — ready for implementation planning
**Milestone:** M8 of `docs/plans/2026-06-15-stillpending-completion-design.md` (Phase 2 — Expand)
**Component:** #24 Transport (extends; component count stays 26)
**Source items:** `stillpending.md` Transport (#24): T18 (site/instance-scoped artifact transport + name-mapping), T20 (per-line/Myers diff for Modified artifacts); plus Tier-2 defect #16 (stale-instance enumeration stub).
## Goal
Extend the file-based, encrypted Transport bundle subsystem — today **central-config-only** (templates, shared scripts, external systems, central DB connections, notification lists, SMTP configs, API methods) — to also promote **site-scoped** and **instance-scoped** artifacts across environments, via a **name-mapping subsystem** that reconciles source-environment site/connection identities to the target environment. Replace the coarse line-count delta in the Modified-artifact diff with a real **per-line Myers diff**. Make the stale-instance enumeration real.
## Scope (locked decisions)
- **D-Scope** — Full M8: T18 + T20 + fix #16.
- **D1 (T18 granularity)** — **Sites + individual instances.** An operator can select a whole `Site` (pulling its `DataConnection`s + all its `Instance`s) and/or cherry-pick individual `Instance`s. Instances carry their `InstanceAttributeOverride`s, `InstanceAlarmOverride`s, `InstanceNativeAlarmSourceOverride`s, `InstanceConnectionBinding`s, and optional `Area` membership (by name).
- **D2 (name-mapping UX)** — **Interactive wizard step + CLI flags.** Auto-match source→target by `SiteIdentifier` / connection `Name`; operator overrides to an existing target or "create new". Unmapped references become blocker rows. CLI mirrors via `--map-site src=dst` and `--map-connection site/src=dst`.
- **D3 (environment-specific data)** — **Carry full config (encrypted secrets).** Site `NodeA/B` + `GrpcNodeA/B` addresses travel; new `DataConnection` `PrimaryConfiguration`/`BackupConfiguration` (endpoint + credentials) travel inside the encrypted `SecretsBlock`; operator overwrites target values post-import as needed.
### Out of scope (deferred to their own brainstorm, per completion design)
- **T19** — direct cluster-to-cluster pull; asymmetric bundle signing; differential/incremental bundles.
## Background — current Transport surface (as audited)
- **Content container:** `BundleContentDto` (`Transport/Serialization/EntityDtos.cs`): `TemplateFolders, Templates, SharedScripts, ExternalSystems, DatabaseConnections, NotificationLists, SmtpConfigs, ApiMethods` (+ legacy read-only `ApiKeys`). Persistence-shaped twin `EntityAggregate`.
- **Selection:** `ExportSelection` (Commons/Types/Transport) carries ID lists per type + `IncludeDependencies`. Across the wire (CLI/ManagementActor) selection is **name**-based; resolved to IDs in `ManagementActor`.
- **Flow:** `BundleExporter.ExportAsync` → `DependencyResolver.ResolveAsync` → `EntitySerializer.ToBundleContent` → `ManifestBuilder.Build` → `BundleSerializer.Pack`. Import: `BundleImporter.LoadAsync` (envelope/zip-bomb guards, manifest+hash validation, decrypt) → `PreviewAsync` (per-type `ArtifactDiff.Compare*` + `DetectBlockersAsync`) → `ApplyAsync` (single EF transaction; per-type `Apply*Async`; intermediate flush; second-pass rewire `ResolveAlarmScriptLinksAsync`/`ResolveCompositionEdgesAsync`; `BundleImported` audit; commit).
- **Conflict model:** `ImportPreviewItem(EntityType, Name, ExistingVersion?, IncomingVersion?, ConflictKind{Identical,Modified,New,Blocker}, FieldDiffJson?, BlockerReason?)`; `ImportResolution(EntityType, Name, ResolutionAction{Add,Overwrite,Skip,Rename}, RenameTo?)`; `ImportResult(BundleImportId, Added, Overwritten, Skipped, Renamed, StaleInstanceIds, AuditEventCorrelation, ApiKeysIgnored)`.
- **Diff:** `ArtifactDiff` emits coarse field changes + `<N lines>` markers for code (`AddCodeChangeIfDifferent`). Per-line diff explicitly deferred (T20).
- **`#16`:** `ImportResult.StaleInstanceIds = Array.Empty<int>()` (BundleImporter.cs ~L736) — known stub.
- **Plumbing:** `TransportCommands.cs` (`ExportBundleCommand`/`PreviewBundleCommand`/`ImportBundleCommand`, base64 envelope); `ManagementActor` handlers ~L2301–2468; CLI `BundleCommands.cs`.
## Site-scoped entity model (as audited)
- **`Site`** (`Commons/Entities/Sites/Site.cs`): `Id`, `Name`, `SiteIdentifier` (unique routing key), `Description?`, `NodeAAddress?`, `NodeBAddress?`, `GrpcNodeAAddress?`, `GrpcNodeBAddress?`. Repo `ISiteRepository` (`GetSiteByIdentifierAsync`, `GetAllSitesAsync`, `GetDataConnectionsBySiteIdAsync`, …).
- **`DataConnection`** (`Commons/Entities/Sites/DataConnection.cs`): `Id`, `SiteId` (FK), `Name` (unique within site), `Protocol`, `PrimaryConfiguration?` (JSON: endpoint+creds), `BackupConfiguration?`, `FailoverRetryCount`. Repo `ISiteRepository`.
- **`Instance`** (`Commons/Entities/Instances/Instance.cs`): `Id`, `TemplateId` (FK), `SiteId` (FK), `AreaId?`, `UniqueName` (globally unique), `State{NotDeployed,Enabled,Disabled}`, + collections `AttributeOverrides`, `AlarmOverrides`, `ConnectionBindings`, `NativeAlarmSourceOverrides`. Repos `ITemplateEngineRepository` (CRUD) + `IDeploymentManagerRepository`.
- **Overrides/bindings:**
- `InstanceAttributeOverride(Id, InstanceId, AttributeName, OverrideValue?, ElementDataType?)`.
- `InstanceAlarmOverride(Id, InstanceId, AlarmCanonicalName, TriggerConfigurationOverride?, PriorityLevelOverride?)`.
- `InstanceNativeAlarmSourceOverride(Id, InstanceId, SourceCanonicalName, ConnectionNameOverride?, SourceReferenceOverride?, ConditionFilterOverride?)` — **name-based** connection ref.
- `InstanceConnectionBinding(Id, InstanceId, AttributeName, DataConnectionId, DataSourceReferenceOverride?)` — **numeric FK** connection ref.
- **Staleness:** `DeploymentService.GetDeploymentComparisonAsync` compares `DeployedConfigSnapshot.RevisionHash` to the freshly-flattened hash. Deployments page surfaces drift; redeploy is the standard flow (Transport never bypasses it).
- **Audited handlers:** `ManagementActor` `HandleCreate/Update/DeleteSite`, `…DataConnection`, `HandleCreateInstance` (`InstanceService`), `HandleSetConnectionBindings`, `HandleSetInstanceOverrides`. Site-scope enforced via `EnforceSiteScope` / `EnforceSiteScopeForInstance`.
## Architecture
### A. Bundle format extension (additive)
Extend the existing format — **no second bundle type** (rejected: duplicates machinery, splits UX).
- `BundleContentDto` gains additive arrays:
- `Sites: IReadOnlyList<SiteDto>` — `SiteDto(SiteIdentifier, Name, Description?, NodeAAddress?, NodeBAddress?, GrpcNodeAAddress?, GrpcNodeBAddress?)`.
- `DataConnections: IReadOnlyList<DataConnectionDto>` — `DataConnectionDto(SiteIdentifier, Name, Protocol, int FailoverRetryCount, SecretsBlock? Secrets)`. `PrimaryConfiguration`/`BackupConfiguration` (endpoint+creds) ride **`Secrets`** (presence-only diff, encrypted-at-rest in the bundle). Named to avoid collision with the existing `DatabaseConnectionDto` (External-System DB defs).
- `Instances: IReadOnlyList<InstanceDto>` — `InstanceDto(UniqueName, TemplateName, SiteIdentifier, AreaName?, InstanceState State, AttributeOverrides[], AlarmOverrides[], NativeAlarmSourceOverrides[], ConnectionBindings[])`, with child DTOs:
- `InstanceAttributeOverrideDto(AttributeName, OverrideValue?, DataType? ElementDataType)`.
- `InstanceAlarmOverrideDto(AlarmCanonicalName, TriggerConfigurationOverride?, PriorityLevelOverride?)`.
- `InstanceNativeAlarmSourceOverrideDto(SourceCanonicalName, ConnectionNameOverride?, SourceReferenceOverride?, ConditionFilterOverride?)`.
- `InstanceConnectionBindingDto(AttributeName, ConnectionName, DataSourceReferenceOverride?)` — note: **`ConnectionName`** (resolved from the source FK at export) replaces the numeric FK, so it is portable.
- `BundleSummary` + manifest `contents[]` extended with `Sites`, `DataConnections`, `Instances` (counts + `dependsOn` edges, e.g. `Instance:Tank01 dependsOn ["Template:Tank","Site:plant-a","DataConnection:plant-a/opc-main"]`).
- **Versioning:** `bundleFormatVersion` stays **1**; `schemaVersion` **1.0 → 1.1** (additive-only; older importers accept minor increments and list the new entity entries as "skipped — unsupported in this version" per the existing forward-compat rule).
### B. Name-mapping subsystem (core of T18)
New Commons type:
```
BundleNameMap(
IReadOnlyList<SiteMapping> Sites,
IReadOnlyList<ConnectionMapping> Connections)
SiteMapping(string SourceSiteIdentifier, MappingAction Action, string? TargetSiteIdentifier)
ConnectionMapping(string SourceSiteIdentifier, string SourceConnectionName, MappingAction Action, string? TargetConnectionName)
enum MappingAction { MapToExisting, CreateNew }
```
- **Detection (PreviewAsync):** after deserialize, collect the distinct source `Site`s and `DataConnection`s referenced by the bundle's instances (via `ConnectionBindings.ConnectionName`, `NativeAlarmSourceOverrides.ConnectionNameOverride`, and `Instance.SiteIdentifier`) **and** the sites/connections carried in the bundle directly. Return them as `RequiredSiteMappings` / `RequiredConnectionMappings` on the preview, each pre-populated with an auto-match: `MapToExisting` when a target `SiteIdentifier` (or `(site,name)` connection) already exists; otherwise `CreateNew`.
- **Wizard Map step:** renders the required mappings as a table; per row a dropdown `{ <existing target> | Create new }`. Connections are grouped under their (mapped) target site.
- **CLI:** `--map-site srcIdentifier=dstIdentifier` (repeatable), `--map-connection srcSiteIdentifier/srcName=dstName` (repeatable); a `--create-missing-sites`/`--create-missing-connections` convenience defaults unmatched rows to `CreateNew` (otherwise unmatched → blocker, preserving fail-safe behaviour for automation).
- **Blockers:** any referenced site/connection that resolves to neither a mapping target nor an in-bundle artifact → blocker row (`BundleImportConnectionUnresolved` / unresolved-site reason). Apply disabled until resolved.
### C. Identity vs conflict (two orthogonal axes)
1. **Identity resolution** — name-map for `Site`/`DataConnection`; `UniqueName` for `Instance`; `Name` for all existing central types. This decides *which* target row an artifact corresponds to.
2. **Conflict resolution** — `ResolutionAction{Add,Overwrite,Skip,Rename}` then applies per artifact exactly as today:
- `MapToExisting` + `Skip` → leave target untouched.
- `MapToExisting` + `Overwrite` → apply bundle config to the existing target.
- `CreateNew` → `Add`.
- `Rename` for instances uses a new `UniqueName`; for sites a new `SiteIdentifier` (rare).
### D. Export flow
- `ExportSelection` gains `SiteIds: IReadOnlyList<int>` + `InstanceIds: IReadOnlyList<int>`.
- `DependencyResolver` expansion (when `IncludeDependencies`):
- selected **Site** → its `DataConnection`s + all its `Instance`s;
- selected **Instance** → its `Site`, its `DataConnection`s (referenced by bindings + native-alarm overrides), and its `Template` (existing template-closure expansion);
- `Instance` → `Template` → existing shared-script / external-system expansion already covers transitive deps.
- `EntitySerializer.ToBundleContent` resolves each instance's `TemplateId`→name, `SiteId`→`SiteIdentifier`, `AreaId`→name, and each `ConnectionBinding.DataConnectionId`→`(siteIdentifier, connectionName)`; carries connection config into `Secrets`.
- Export wizard Step 1 adds a **Sites** group (with nested instances) + a flat **Instances** selector; Step 2 dependency review shows the new edges. `RequireDesign` unchanged.
### E. Import flow + wizard
- Wizard: Upload → Passphrase → **Map** *(new; shown only when the bundle contains site/instance-scoped artifacts)* → Diff & resolve → Confirm → Result.
- `IBundleImporter.PreviewAsync` returns the diff **plus** required mappings. `ApplyAsync(sessionId, resolutions, nameMap, user, ct)` (signature extended with `nameMap`) runs in the existing single EF transaction:
1. Resolve/create target `Site`s per the site-map (CreateNew → full `SiteDto` config; MapToExisting → use target, honour conflict action).
2. Resolve/create target `DataConnection`s per the connection-map (config from `Secrets`).
3. Existing central-config apply (`ApplyTemplatesAsync`, …) — unchanged.
4. Upsert `Instance`s: resolve `TemplateName`→target template id (blocker if unresolved), `SiteIdentifier`→target site id (from map), `AreaName`→area (create-if-missing within target site), `State` forced to `NotDeployed` on import; write overrides; write `ConnectionBinding`s with `ConnectionName`→target `DataConnectionId` (from map) in the second-pass rewire; rewrite `NativeAlarmSourceOverride.ConnectionNameOverride` to the mapped target name.
5. Semantic validation (existing two-tier) — unchanged.
6. `BundleImported` audit + `BundleImportId` correlation (existing) — now also covers the per-entity Site/DataConnection/Instance rows because we route through the audited repository methods.
7. Commit.
- Reuses audited repository paths so per-entity audit rows + `BundleImportId` correlation flow automatically; site-scope (`EnforceSiteScope`) honoured for each target site at import. `RequireAdmin` unchanged.
- **Blockers:** instance whose `TemplateName` resolves in neither bundle nor target; unresolved connection/site references (see B).
### F. T20 — per-line Myers diff
- New pure helper `LineDiffer` (Transport project or Commons/Types/Transport) implementing **Myers O(ND)** LCS over `\n`-split lines (custom, no third-party library — matches the custom-`KpiTrendChart` precedent; central package management discourages new deps).
- Output: structured hunks of `{ Op: Context|Add|Remove, Line, OldLineNo?, NewLineNo? }`, **size-capped** by `TransportOptions` (`MaxDiffLines` default ~400, `MaxDiffHunks`); beyond the cap, emit a `truncated:true` marker + a final `<+N / -M more lines>` summary so very large scripts don't bloat `FieldDiffJson`.
- `ArtifactDiff.AddCodeChangeIfDifferent` (and the script-children path) replace the `<N lines>` marker with the hunk diff embedded in `FieldDiffJson` for `TemplateScript.Code`, `SharedScript.Code`, `ApiMethod.Script` (and `ExternalSystemMethod` body if present). Non-code fields keep coarse value diffs.
- Import wizard Modified rows render the `+/-` line view (fulfils the spec's Step-3 "shows `+/-/~` line diff" promise that the implementation never met). bUnit + Playwright coverage for the rendered diff.
### G. #16 — real stale-instance enumeration
- `ImportResult.StaleInstanceIds` becomes real. Inside `ApplyAsync`, before commit: for every **Template Overwritten** by the import, enumerate existing **deployed** target instances of that template and compute hash drift (reuse the flatten + `DeployedConfigSnapshot.RevisionHash` comparison the Deployments page already uses; called inside the open transaction against staged changes, or post-flush pre-commit). Return the drifting instance ids.
- Directly-imported instances land `NotDeployed` → surfaced as **new** in the result (not "stale"; stale = deployed-and-drifted).
- Step 4 "N instances will become stale" shows the real count; Step 5's existing Deployments deep-link gets real ids. UI already wired — this fills it with data.
## Error handling (additions to the existing table)
| Where | Failure | Surfaced as |
|---|---|---|
| Map | Source site referenced, no auto-match, not mapped, `CreateNew` not chosen | Blocker row: "Site '{id}' not mapped to a target" |
| Map | Connection referenced by an instance binding, unmapped & not in bundle | Blocker row (`BundleImportConnectionUnresolved`) |
| Apply | Instance's `TemplateName` resolves nowhere | Blocker row before Apply enabled |
| Apply | Create-new site/connection but config absent (unencrypted bundle stripped) | Created with available fields; warning event; operator completes config post-import |
All-or-nothing per bundle (unchanged): the EF transaction wraps site/connection/instance writes + audit; rollback removes everything.
## Security
- DataConnection endpoints + credentials ride the **encrypted** `SecretsBlock`; unencrypted export still trips `UnencryptedBundleExport`. Diffs are presence-only for secrets (never echo endpoints/creds).
- Import remains `RequireAdmin`; site-scope enforced per target site.
- No new HTTP channel; bundle bytes ride the existing base64 `/management` envelope (already raised to 200 MB).
## Authorization (unchanged)
| Operation | Role |
|---|---|
| Export page / `bundle export` (incl. sites/instances) | `RequireDesign` |
| Import page / `bundle preview`+`import` (incl. sites/instances) | `RequireAdmin` |
## Audit
- Reuse existing per-entity audited repository methods for Site/DataConnection/Instance create/update so rows carry `BundleImportId`.
- New warning events (mirroring existing `BundleImportAlarmScriptUnresolved` / `…CompositionUnresolved`): `BundleImportConnectionUnresolved`, `BundleImportSiteUnresolved` (when a non-blocking soft-reference can't bind).
## Testing strategy
- **Unit:** `LineDiffer` (Myers correctness + size-cap + edge cases: empty, identical, all-add, all-remove, CRLF); `BundleNameMap` auto-match + CLI flag parsing; `EntitySerializer` round-trip for `SiteDto`/`DataConnectionDto`/`InstanceDto` (incl. connection-name resolution); `ArtifactDiff` Site/Connection/Instance compare.
- **Integration:** export a site (+connections+instances) → import to a fresh target with a name-map (create-new) and to a populated target (map-to-existing + Overwrite/Skip); FK remapping of `ConnectionBinding`/`NativeAlarmSourceOverride`; blocker on unresolved template/connection; `#16` stale enumeration (overwrite a template that has deployed instances → assert returned ids drift); rollback leaves nothing partial.
- **bUnit:** Map step rendering + auto-match; Modified row `+/-` diff render.
- **Playwright:** export-with-site → import-with-mapping happy path; diff line view.
- **Targeted builds/tests per task; full-solution build + `bash docker/deploy.sh` rebuild + live smoke at integration.**
## Forward compatibility
- Older importers (schemaVersion 1.0) reading a 1.1 bundle: unknown `Site`/`DataConnection`/`Instance` entries listed as "skipped — unsupported in this version" (existing rule). Central-config artifacts still import.
- `bundleFormatVersion` unchanged (1) — no hard refusal.
## Execution plan (waves — for writing-plans)
- **Wave A (format + name-map foundation):** Commons DTOs/types (`SiteDto`/`DataConnectionDto`/`InstanceDto` + children, `BundleNameMap`, extended `ExportSelection`/`BundleSummary`/`ManifestContentEntry` usage, extended `ImportResult`/preview required-mappings), `LineDiffer` pure helper. *(high-risk: data contracts; standard: LineDiffer)*
- **Wave B (export):** `DependencyResolver` site/instance expansion; `EntitySerializer` site/connection/instance mapping (FK→name); `ManifestBuilder`/summary counts; `ExportSelection` wiring + `ManagementActor.HandleExportBundle` name resolution; CLI `bundle export` site/instance flags. *(high-risk)*
- **Wave C (diff + preview):** `ArtifactDiff` Myers integration (T20) + Site/Connection/Instance compare; `BundleImporter.PreviewAsync` required-mapping detection + blocker scan extension. *(standard/high-risk)*
- **Wave D (apply + name-map + #16):** `BundleImporter.ApplyAsync` `nameMap` param + site/connection resolve-or-create + instance upsert + FK rewire; `#16` stale enumeration; `ManagementActor.HandleImportBundle`/`PreviewBundle` plumbing; CLI `bundle import` `--map-*` flags. *(high-risk; serial reviews)*
- **Wave E (UI):** Export wizard Sites/Instances selection; Import wizard Map step + Modified `+/-` diff render. *(standard)*
- **Integration:** docs (Component-Transport.md, Component-CLI.md, README, stillpending.md, completion-design.md), full build, docker rebuild, Playwright, live smoke, end-to-end cross-cluster trace.
## Open items / risks
- The name-map detection must be exhaustive: every place a transported instance points at a connection (`ConnectionBinding`, `NativeAlarmSourceOverride`) and at a site must be covered, or an import silently drops a binding. Integration tests assert FK remap explicitly.
- `#16` hash-drift computation inside the open transaction must use the post-staged template state; verify it doesn't double-count directly-imported (NotDeployed) instances.
- `Area` create-if-missing is the lightest reasonable choice; if areas grow fields later, revisit.
## Next step
Hand off to writing-plans to produce the bite-sized, task-metadata'd implementation plan + `.tasks.json`, then execute subagent-driven in this worktree.
+457
View File
@@ -0,0 +1,457 @@
# M8 — Transport (T18, T20) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.
**Goal:** Extend the Transport bundle subsystem to promote **site-scoped** and **instance-scoped** artifacts across environments via a **name-mapping subsystem** (T18), replace the coarse line-count delta with a real **per-line Myers diff** (T20), and make the **stale-instance enumeration** real (#16).
**Architecture:** Additive extension of the existing `.scadabundle` format (`bundleFormatVersion` stays 1, `schemaVersion` 1.0→1.1). New bundle DTOs (`SiteDto`/`DataConnectionDto`/`InstanceDto`), a transient `BundleNameMap` resolved at import (interactive wizard step + CLI flags), and a custom pure `LineDiffer`. Identity is resolved by name-map (Site/DataConnection) + `UniqueName` (Instance); conflict resolution (Add/Overwrite/Skip/Rename) is unchanged. No new EF tables/columns — Site/DataConnection/Instance already exist in the central DB.
**Tech Stack:** C#/.NET 10, EF Core 10 (MS SQL central / SQLite tests), Blazor Server (Bootstrap, no third-party component libs), System.Text.Json, xUnit + bUnit + NSubstitute + Playwright. `TreatWarningsAsErrors=true`; central package management (no new packages — Myers diff is hand-rolled, cf. custom-SVG `KpiTrendChart`).
**Design doc:** `docs/plans/2026-06-18-m8-transport-design.md` (decisions D1–D3).
**No EF migration:** confirmed — the feature persists no new schema. Sites/DataConnections/Instances/overrides/bindings already exist; `BundleNameMap` and `LineDiffer` output are transient.
**Execution conventions (from CLAUDE.md + standing constraints):**
- Work in worktree `worktree-m8-transport` (branch `worktree-worktree-m8-transport`, off `main` @ `0c7774ac`). Worktree root: `/Users/dohertj2/Desktop/ScadaBridge/.claude/worktrees/worktree-m8-transport/`. Implementers do **NOT** create their own worktrees — they edit files under this worktree root.
- Commit **pathspec** form only: `git commit -m "msg" -- <paths>` (`-m` BEFORE `--`). New files need `git add <path>` first. **Never** `git add -A`/`-a`. Retry on `index.lock`.
- ≤2–3 concurrent committers per wave; post-wave HEAD-presence check (`git merge-base --is-ancestor <sha> HEAD`).
- Targeted builds/tests per task; full-solution build + `bash docker/deploy.sh` + live smoke only at integration.
---
## Wave A — Format + name-map foundation
### Task A1: Commons name-map types + preview/selection/summary extensions
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** A2, A3
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleNameMap.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ImportPreview.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/BundleNameMapTests.cs` (new)
**Step 1 — Add the name-map types** (`BundleNameMap.cs`):
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
public enum MappingAction { MapToExisting, CreateNew }
public sealed record SiteMapping(
string SourceSiteIdentifier,
MappingAction Action,
string? TargetSiteIdentifier); // required when Action == MapToExisting
public sealed record ConnectionMapping(
string SourceSiteIdentifier,
string SourceConnectionName,
MappingAction Action,
string? TargetConnectionName); // required when Action == MapToExisting
public sealed record BundleNameMap(
IReadOnlyList<SiteMapping> Sites,
IReadOnlyList<ConnectionMapping> Connections)
{
public static BundleNameMap Empty { get; } =
new(Array.Empty<SiteMapping>(), Array.Empty<ConnectionMapping>());
}
```
**Step 2 — Extend `ExportSelection`** — add `IReadOnlyList<int> SiteIds` and `IReadOnlyList<int> InstanceIds` (default to empty in any factory/usages; additive — keep existing positional members at the front to avoid breaking call sites, or convert to a record `with` friendly shape).
**Step 3 — Extend `BundleSummary`** — add `int Sites`, `int DataConnections`, `int Instances` (additive trailing members; default 0 so existing manifest deserialization of older bundles still works).
**Step 4 — Extend `ImportPreview`** — add the required-mapping surface:
```csharp
public sealed record RequiredSiteMapping(string SourceSiteIdentifier, string SourceSiteName, string? AutoMatchTargetIdentifier);
public sealed record RequiredConnectionMapping(string SourceSiteIdentifier, string SourceConnectionName, string? AutoMatchTargetName);
// ImportPreview gains:
// IReadOnlyList<RequiredSiteMapping> RequiredSiteMappings
// IReadOnlyList<RequiredConnectionMapping> RequiredConnectionMappings
// Default both to empty for central-config-only bundles.
```
**Step 5 — Tests** — `BundleNameMapTests`: `Empty` is empty; record equality; `MapToExisting` carries a target; round-trips through `System.Text.Json` with `BundleJsonOptions`.
**Step 6 — Build + test:** `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons` then `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter BundleNameMap`. Both green.
**Step 7 — Commit:** `git add` the new files, then `git commit -m "feat(transport): name-map types + preview/selection/summary extensions (M8 A1)" -- src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleNameMap.cs src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ImportPreview.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/BundleNameMapTests.cs`
---
### Task A2: Transport bundle DTOs — Site/DataConnection/Instance + BundleContentDto
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** A1, A3
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs` (new — or extend an existing serialization test)
**Step 1 — Add DTO records** (in `EntityDtos.cs`, namespace `ZB.MOM.WW.ScadaBridge.Transport.Serialization`):
```csharp
public sealed record SiteDto(
string SiteIdentifier, string Name, string? Description,
string? NodeAAddress, string? NodeBAddress,
string? GrpcNodeAAddress, string? GrpcNodeBAddress);
public sealed record DataConnectionDto(
string SiteIdentifier, string Name, string Protocol,
int FailoverRetryCount, SecretsBlock? Secrets); // Primary/BackupConfiguration ride Secrets
public sealed record InstanceAttributeOverrideDto(string AttributeName, string? OverrideValue, DataType? ElementDataType);
public sealed record InstanceAlarmOverrideDto(string AlarmCanonicalName, string? TriggerConfigurationOverride, int? PriorityLevelOverride);
public sealed record InstanceNativeAlarmSourceOverrideDto(string SourceCanonicalName, string? ConnectionNameOverride, string? SourceReferenceOverride, string? ConditionFilterOverride);
public sealed record InstanceConnectionBindingDto(string AttributeName, string ConnectionName, string? DataSourceReferenceOverride);
public sealed record InstanceDto(
string UniqueName, string TemplateName, string SiteIdentifier, string? AreaName,
InstanceState State,
IReadOnlyList<InstanceAttributeOverrideDto> AttributeOverrides,
IReadOnlyList<InstanceAlarmOverrideDto> AlarmOverrides,
IReadOnlyList<InstanceNativeAlarmSourceOverrideDto> NativeAlarmSourceOverrides,
IReadOnlyList<InstanceConnectionBindingDto> ConnectionBindings);
```
(Use the correct `using`s for `DataType` and `InstanceState` from Commons. `SecretsBlock` already exists in this file.)
**Step 2 — Extend `BundleContentDto`** — add additive trailing members:
`IReadOnlyList<SiteDto> Sites`, `IReadOnlyList<DataConnectionDto> DataConnections`, `IReadOnlyList<InstanceDto> Instances` (give each a `= ...` default of an empty array so deserializing older bundles yields empty lists, never null).
**Step 3 — Extend `EntityAggregate`** (the persistence-shaped twin) — add the matching entity collections (`IReadOnlyList<Site>`, `IReadOnlyList<DataConnection>`, `IReadOnlyList<Instance>`), defaulting to empty.
**Step 4 — Tests** — serialize a `BundleContentDto` with sites/connections/instances populated + a legacy content blob with only central-config arrays; assert round-trip equality and that legacy blobs deserialize with empty (non-null) new arrays.
**Step 5 — Build + test:** `dotnet build src/ZB.MOM.WW.ScadaBridge.Transport`; `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter BundleDtoSerialization`. Green.
**Step 6 — Commit:** pathspec commit of `EntityDtos.cs` + the test. Message `feat(transport): site/connection/instance bundle DTOs (M8 A2)`.
---
### Task A3: `LineDiffer` — pure Myers per-line diff helper (T20)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** A1, A2
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/LineDiffer.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/LineDifferTests.cs` (new)
**Step 1 — Implement** a pure static `LineDiffer` using the Myers O(ND) algorithm over `\n`-split lines (normalize CRLF→LF before splitting). Public surface:
```csharp
public enum LineDiffOp { Context, Add, Remove }
public sealed record LineDiffLine(LineDiffOp Op, string Text, int? OldLineNo, int? NewLineNo);
public sealed record LineDiffResult(IReadOnlyList<LineDiffLine> Lines, bool Truncated, int AddedCount, int RemovedCount);
public static class LineDiffer
{
// maxLines caps the emitted Lines; beyond it, Truncated=true and Lines are cut
// while AddedCount/RemovedCount still reflect the FULL diff totals.
public static LineDiffResult Diff(string? oldText, string? newText, int maxLines = 400);
}
```
**Step 2 — Tests** (table-driven where sensible):
- identical → no Add/Remove lines, all Context (or empty), `Truncated=false`.
- pure add (old empty) → all Add; pure remove (new empty) → all Remove.
- single-line change → 1 Remove + 1 Add with correct line numbers.
- interleaved change preserves LCS context.
- CRLF normalization: `"a\r\nb"` vs `"a\nb"` → no diff.
- null inputs handled (null treated as empty).
- size cap: a 1000-line all-add diff with `maxLines=400` → `Lines.Count<=400`, `Truncated=true`, `AddedCount==1000`.
**Step 3 — Build + test:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter LineDiffer`. Green.
**Step 4 — Commit:** pathspec commit. Message `feat(transport): pure Myers LineDiffer helper (M8 A3, T20)`.
---
## Wave B — Export
### Task B1: DependencyResolver site/instance expansion
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** B2, B3 (disjoint files; all consume A2's `EntityAggregate` shape)
**Blocked by:** A2
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Export/ResolvedExport.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs` (extend)
**Steps:**
1. Inject `ISiteRepository` (sites + data connections) and `IDeploymentManagerRepository`/`ITemplateEngineRepository` (instances + overrides + bindings) as needed.
2. Gather selected `SiteIds` → `Site` + `GetDataConnectionsBySiteIdAsync` + `GetInstancesBySiteIdAsync`. Gather selected `InstanceIds` → `Instance` (+ overrides, bindings via the repo getters from the explore report: `GetOverridesByInstanceIdAsync`, `GetAlarmOverridesByInstanceIdAsync`, `GetNativeAlarmSourceOverridesByInstanceIdAsync`, `GetConnectionBindingsByInstanceIdAsync`).
3. Dependency expansion (when `IncludeDependencies`): instance → its `Site`; instance → the `DataConnection`s referenced by its bindings (`DataConnectionId`) + native-alarm overrides (`ConnectionNameOverride` resolved within the instance's site); instance → its `Template` (feed into the existing template closure so shared scripts / external systems expand transitively).
4. Add sites/connections/instances to `ResolvedExport` (new members) in a stable, deterministic order (sites by `SiteIdentifier`; connections by `(SiteIdentifier, Name)`; instances by `UniqueName`). Emit `ManifestContentEntry` rows with `dependsOn` edges (`Instance:{u} dependsOn ["Template:{t}","Site:{s}","DataConnection:{s}/{c}"]`).
5. Tests: select a site → resolves its connections + instances; select an instance with deps → pulls site + connections + template; dedup when both a site and one of its instances are selected.
6. Build + targeted test; pathspec commit `feat(transport): resolve site/instance export selection + deps (M8 B1)`.
---
### Task B2: EntitySerializer site/connection/instance mapping (both directions)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** B1, B3
**Blocked by:** A2
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs` (extend)
**Steps:**
1. `ToBundleContent` (export): map `Site`→`SiteDto`; `DataConnection`→`DataConnectionDto` carrying `Primary/BackupConfiguration` into `Secrets` (keys e.g. `"PrimaryConfiguration"`, `"BackupConfiguration"`); `Instance`→`InstanceDto` resolving `TemplateId`→template name, `SiteId`→`SiteIdentifier`, `AreaId`→area name, and each `ConnectionBinding.DataConnectionId`→`(SiteIdentifier, ConnectionName)` (look up the connection within the aggregate's connections / repo-provided map). Map all four override/binding child collections.
2. `FromBundleContent` (import deserialize): reconstruct `Site`/`DataConnection`/`Instance` entities with synthetic ids (ordinal), restore `Primary/BackupConfiguration` from `Secrets`, keep `ConnectionName` (string) on bindings for the apply-time rewire (do NOT try to assign a numeric `DataConnectionId` here — the importer resolves it against the target via the name-map).
3. Tests: round-trip a site+connection+instance aggregate; assert connection config lands in `Secrets` (not echoed elsewhere); assert binding carries `ConnectionName`; assert override collections survive.
4. Build + targeted test; pathspec commit `feat(transport): serialize site/connection/instance entities↔DTOs (M8 B2)`.
---
### Task B3: ManifestBuilder + summary counts for new types
**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** B1, B2
**Blocked by:** A1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/ManifestBuilder.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs` (the `BundleSummary` construction block, ~L90–98)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/ManifestBuilderTests.cs` (extend)
**Steps:**
1. Populate the new `BundleSummary` counts (Sites/DataConnections/Instances) at export.
2. Bump `CurrentSchemaVersion` `"1.0"` → `"1.1"` (keep `CurrentBundleFormatVersion = 1`). Confirm `ManifestValidator` accepts 1.1 (minor increments are additive-accepted — verify the version-gating logic only hard-refuses on `bundleFormatVersion`, not `schemaVersion`).
3. Tests: summary counts reflect new arrays; schemaVersion is "1.1"; a manifest with schemaVersion "1.0" still validates Ok.
4. Build + targeted test; pathspec commit `feat(transport): manifest summary + schemaVersion 1.1 (M8 B3)`.
---
### Task B4: Export plumbing — selection wiring, command, ManagementActor, CLI
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** C1 (disjoint files)
**Blocked by:** B1, B2, B3
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs` (accept site/instance selection)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs` (`ExportBundleCommand` gains `SiteNames`, `InstanceNames`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleExportBundle`: resolve site/instance **names→IDs** like the existing `ResolveIds<T>` helper; populate `ExportSelection.SiteIds/InstanceIds`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs` (`bundle export` gains `--sites A,B` and `--instances X,Y`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs` (extend) + ManagementActor handler coverage if present.
**Steps:**
1. Thread the new selection through `ExportBundleCommand` → `HandleExportBundle` (name→ID resolution; sites by `SiteIdentifier` or Name, instances by `UniqueName`) → `ExportSelection`.
2. CLI: parse `--sites`/`--instances` (comma-split, same style as `--templates`).
3. Tests: CLI parses the flags; handler resolves names; `--all` includes sites/instances too (decide + document: `--all` should export every site + instance as well — confirm in the handler).
4. Build affected projects + targeted tests; pathspec commit `feat(transport): export site/instance selection via CLI + ManagementActor (M8 B4)`.
---
## Wave C — Diff + preview
### Task C1: ArtifactDiff — Myers integration (T20) + Site/Connection/Instance compares
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** B4
**Blocked by:** A2, A3
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs` (new or extend)
**Steps:**
1. **T20:** replace `AddCodeChangeIfDifferent`'s `<{n} lines>` marker with a `LineDiffer.Diff(old, new, options.MaxDiffLines)` call, embedding the structured `LineDiffResult` into the `FieldChange` (extend `FieldChange` with an optional `lineDiff` payload, or serialize the hunks into `NewValue`/a new field — keep `FieldDiffJson` JSON-shaped and capped). Apply to `TemplateScript.Code`, `SharedScript.Code`, `ApiMethod.Script`. Keep `DiffScriptChildren` line view consistent.
2. Add `CompareSite(SiteDto, Site?)`, `CompareDataConnection(DataConnectionDto, DataConnection?)`, `CompareInstance(InstanceDto, Instance?, existing children)` producing `ImportPreviewItem`s. Connection config diffs are **presence-only** on `Secrets` (mirror the existing external-system/db-connection secret handling — never echo endpoint/creds). Instance compares the override/binding child collections by name (reuse `DiffChildren`).
3. Tests: code field now yields a structured `+/-` diff (assert hunks); secret presence-only; new-vs-modified-vs-identical for site/connection/instance.
4. Build + targeted test; pathspec commit `feat(transport): Myers code diff + site/connection/instance compare (M8 C1, T20)`.
---
### Task C2: BundleImporter.PreviewAsync — new-type diff + required-mapping detection + blockers
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (BundleImporter.cs is the critical-path file for C2→D1→D2)
**Blocked by:** B2, C1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs` (`PreviewAsync` + `DetectBlockersAsync`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs` (extend)
**Steps:**
1. Deserialize the new arrays; call `ArtifactDiff.CompareSite/CompareDataConnection/CompareInstance` for each (bulk-fetch target sites/connections/instances by identifier/name to avoid N+1).
2. **Required-mapping detection:** collect distinct source `SiteIdentifier`s and `(site, connection)` pairs referenced by the bundle's instances (bindings + native-alarm overrides) and carried directly; auto-match against target `ISiteRepository.GetSiteByIdentifierAsync` / `GetDataConnectionsBySiteIdAsync`; populate `ImportPreview.RequiredSiteMappings`/`RequiredConnectionMappings`.
3. **Blocker scan extension:** an instance whose `TemplateName` is neither in the bundle nor in the target → blocker; a referenced connection that maps to neither an existing target (auto/explicit) nor an in-bundle connection → blocker (`BlockerReason` text).
4. Tests: preview surfaces required mappings with correct auto-match; blocker on missing template; blocker on unresolvable connection.
5. Build + targeted integration test; pathspec commit `feat(transport): preview diff + required-mapping detection + blockers (M8 C2)`.
---
## Wave D — Apply + name-map + #16
### Task D1: BundleImporter.ApplyAsync — nameMap, resolve-or-create, instance upsert + FK rewire
**Classification:** high-risk
**Estimated implement time:** ~8 min (heaviest task — critical path; serial reviews + final integration review)
**Parallelizable with:** none
**Blocked by:** C2
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs` (`ApplyAsync` + new `Apply{Sites,DataConnections,Instances}Async` helpers + rewire)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IBundleImporter.cs` (add `nameMap` param to `ApplyAsync`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs` (extend) + a new `SiteInstanceImportTests.cs`
**Steps:**
1. Extend `ApplyAsync(sessionId, resolutions, **BundleNameMap nameMap**, user, ct)`. Inject `ISiteRepository` + the instance repos.
2. Inside the existing transaction, **before** central-config apply: resolve/create target `Site`s per `nameMap.Sites` (`CreateNew`→`AddSiteAsync` with full `SiteDto` config; `MapToExisting`→load target, honour the site's conflict resolution for Overwrite/Skip). Build a `sourceSiteIdentifier → targetSite` map.
3. Resolve/create target `DataConnection`s per `nameMap.Connections` (config from `Secrets`); build a `(sourceSiteIdentifier, sourceConnName) → targetDataConnectionId` map.
4. After central-config apply + the existing intermediate flush (so template/area ids are materialized): **upsert instances** — resolve `TemplateName`→target template id, `SiteIdentifier`→target site id (from map), `AreaName`→area (create-if-missing within target site), force `State=NotDeployed`; write `AttributeOverride`/`AlarmOverride`/`NativeAlarmSourceOverride` rows; write `ConnectionBinding`s with `DataConnectionId` resolved via the connection map; rewrite each `NativeAlarmSourceOverride.ConnectionNameOverride` to the mapped **target** connection name.
5. Mirror the existing audited-write pattern (follow `ApplyTemplatesAsync`) so per-entity rows carry `BundleImportId` via `IAuditCorrelationContext`. Honour `EnforceSiteScope`-equivalent checks for the target sites if the importer has access to the caller principal (else note as a follow-up — import is already `RequireAdmin`).
6. Tests: import a site+connections+instances into a **fresh** target with a create-new map → instances exist, `NotDeployed`, bindings point at created connections, native-alarm overrides reference target connection names; import into a **populated** target with map-to-existing + Overwrite/Skip; assert FK remap explicitly; rollback on a forced failure leaves nothing partial.
7. Build + targeted integration tests; pathspec commit `feat(transport): apply site/instance import with name-map + FK rewire (M8 D1, T18)`.
---
### Task D2: #16 — real stale-instance enumeration
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** D3 (disjoint files)
**Blocked by:** D1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs` (compute `StaleInstanceIds` before commit)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs` (extend)
**Steps:**
1. Before commit, for every **Template Overwritten** by this import, enumerate existing **deployed** target instances of that template (`GetInstancesByTemplateIdAsync`) and compute hash drift — reuse the flatten + `DeployedConfigSnapshot.RevisionHash` comparison the Deployments page uses (inject the flattening pipeline / a small `IStaleInstanceProbe`, or reuse `DeploymentService.GetDeploymentComparisonAsync`-equivalent against staged state). Collect drifting instance ids into `ImportResult.StaleInstanceIds` (replace the `Array.Empty<int>()` stub at ~L736).
2. Directly-imported instances are `NotDeployed` → must NOT be counted as stale (they're new). Assert this in tests.
3. Tests: overwrite a template that has a deployed instance → that instance id appears in `StaleInstanceIds`; an unchanged template's deployed instances do not; freshly-imported instances are absent.
4. Build + targeted test; pathspec commit `fix(transport): real stale-instance enumeration in ImportResult (M8 D2, #16)`.
---
### Task D3: Import plumbing — name-map through command, ManagementActor, CLI
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** D2 (disjoint files)
**Blocked by:** D1, B4 (shares ManagementActor.cs / BundleCommands.cs / TransportCommands.cs with B4 — must run after B4)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs` (`PreviewBundleResult` carries required mappings; `ImportBundleCommand` carries an optional serialized `BundleNameMap` + create-missing flags)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandlePreviewBundle` returns required mappings; `HandleImportBundle` builds the `BundleNameMap` from CLI flags / auto-match and passes it to `ApplyAsync`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs` (`--map-site src=dst` repeatable, `--map-connection site/src=dst` repeatable, `--create-missing-sites`, `--create-missing-connections`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs` (extend) + ManagementActor coverage.
**Steps:**
1. Extend the command contracts (additive). `HandleImportBundle`: after preview, construct `BundleNameMap` from `--map-*` flags; unmatched required mappings default to `CreateNew` only when `--create-missing-*` is set, else abort with a blocker-style error (fail-safe for automation).
2. CLI flag parsing (`Dictionary`-style repeatable options; parse `site/src=dst`).
3. Tests: flags parse into a `BundleNameMap`; preview result carries required mappings; import with `--map-site` round-trips end-to-end against a test target.
4. Build + targeted tests; pathspec commit `feat(transport): name-map plumbing via CLI + ManagementActor (M8 D3)`.
---
## Wave E — UI
### Task E1: Export wizard — Sites/Instances selection
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** E2 (disjoint razor files)
**Blocked by:** B4
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor` (+ `.razor.cs` if present)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs` (extend)
**Steps:**
1. Step 1 of the export wizard gains a **Sites** group (each site expandable to its instances, checkbox-selectable) and a flat **Instances** selector. Wire selection into the export call (site/instance names). Step 2 dependency review shows the new edges.
2. bUnit: sites/instances render + selection flows into the export request; `RequireDesign` enforced.
3. Build + targeted bUnit; pathspec commit `feat(transport-ui): export wizard site/instance selection (M8 E1)`.
---
### Task E2: Import wizard — Map step + Modified `+/-` diff render
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** E1
**Blocked by:** D3
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor` (+ `.razor.cs`)
- Create (optional): `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/LineDiffView.razor` (renders `LineDiffResult`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs` (extend)
**Steps:**
1. Insert a **Map** step (between Passphrase and Diff) shown only when `RequiredSiteMappings`/`RequiredConnectionMappings` are non-empty: a table with auto-match defaults + per-row dropdown (existing target | Create new). Feed the chosen `BundleNameMap` into preview-refresh / apply.
2. Modified rows render the `+/-` line diff (parse `FieldDiffJson` line-diff payload → `LineDiffView`). Show the truncation marker when `Truncated`.
3. bUnit: Map step appears only for site/instance bundles + auto-match defaults; Modified row shows `+/-` lines.
4. Build + targeted bUnit; pathspec commit `feat(transport-ui): import Map step + line-diff view (M8 E2)`.
---
## Integration
### Task INT: Docs, full build, docker rebuild, Playwright, live smoke, end-to-end trace
**Classification:** high-risk
**Estimated implement time:** ~10 min
**Parallelizable with:** none
**Blocked by:** E1, E2
**Files:**
- Modify: `docs/requirements/Component-Transport.md` (site/instance scope, name-mapping, line diff; remove "does not move site-scoped artifacts" stance; update format §, wizard §, error table, schemaVersion 1.1)
- Modify: `docs/requirements/Component-CLI.md` + `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (new `bundle export --sites/--instances`, `bundle import --map-site/--map-connection/--create-missing-*`)
- Modify: `README.md` (Transport row if scope summary changed)
- Modify: `stillpending.md` (mark T18/T20 delivered; #16 fixed)
- Modify: `docs/plans/2026-06-15-stillpending-completion-design.md` (mark M8 delivered)
- Modify: `CLAUDE.md` (Transport key-decision bullet if warranted)
**Steps:**
1. **Full-solution build:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 warnings / 0 errors (TreatWarningsAsErrors).
2. **Full targeted test sweep:** Transport unit + integration, Commons, CLI, CentralUI bUnit. Record pass counts; triage any pre-existing reds (don't attribute new failures to "pre-existing" without proof).
3. **Final whole-branch integration review** (code-reviewer over `git diff main..HEAD`) focused on: the cross-cluster/name-map remap chain end-to-end (export FK→name → import name→FK); secret hygiene (no endpoint/creds echoed in diffs/logs); transaction all-or-nothing with the new entity writes; `#16` not double-counting NotDeployed instances; schemaVersion forward-compat.
4. **Docker rebuild:** `bash docker/deploy.sh`; verify central-a/central-b `/health/ready` 200 + traefik `/health/active` 200.
5. **Live smoke:** CLI `bundle export --sites …` against the running cluster → `bundle preview` → `bundle import --map-site …` round-trip; confirm instances land + Deployments page shows them.
6. **Playwright:** export-with-site + import-with-mapping happy path + line-diff render.
7. Update docs; pathspec commit docs + any final fixes. Message `docs+integration(m8): Transport site/instance transport, name-map, Myers diff, stale enum (M8 INT)`.
8. Hand off to `finishing-a-development-branch`.
---
## Task dependency summary
```
A1 ─┐
A2 ─┼─(parallel)
A3 ─┘
B1 ─┐
B2 ─┼─(parallel, after A2; B3 after A1)
B3 ─┘
B4 (after B1,B2,B3) ∥ C1 (after A2,A3)
C2 (after B2,C1)
D1 (after C2)
D2 (after D1) ∥ D3 (after D1,B4)
E1 (after B4) ∥ E2 (after D3)
INT (after E1,E2)
```
Committer concurrency stays ≤3 per wave. `BundleImporter.cs` is the serial spine (C2→D1→D2); `ManagementActor.cs`/`BundleCommands.cs`/`TransportCommands.cs` are serialized across B4→D3.
@@ -0,0 +1,21 @@
{
"planPath": "docs/plans/2026-06-18-m8-transport.md",
"tasks": [
{"id": 210, "subject": "M8-A1: Commons name-map types + preview/selection/summary extensions", "status": "pending"},
{"id": 211, "subject": "M8-A2: Transport bundle DTOs — Site/DataConnection/Instance + BundleContentDto", "status": "pending"},
{"id": 212, "subject": "M8-A3: LineDiffer — pure Myers per-line diff helper (T20)", "status": "pending"},
{"id": 213, "subject": "M8-B1: DependencyResolver site/instance expansion", "status": "pending", "blockedBy": [211]},
{"id": 214, "subject": "M8-B2: EntitySerializer site/connection/instance mapping (both directions)", "status": "pending", "blockedBy": [211]},
{"id": 215, "subject": "M8-B3: ManifestBuilder + summary counts + schemaVersion 1.1", "status": "pending", "blockedBy": [210]},
{"id": 216, "subject": "M8-B4: Export plumbing — selection wiring, command, ManagementActor, CLI", "status": "pending", "blockedBy": [213, 214, 215]},
{"id": 217, "subject": "M8-C1: ArtifactDiff — Myers code diff (T20) + Site/Connection/Instance compares", "status": "pending", "blockedBy": [211, 212]},
{"id": 218, "subject": "M8-C2: BundleImporter.PreviewAsync — new-type diff + required-mapping detection + blockers", "status": "pending", "blockedBy": [214, 217]},
{"id": 219, "subject": "M8-D1: BundleImporter.ApplyAsync — nameMap, resolve-or-create, instance upsert + FK rewire", "status": "pending", "blockedBy": [218]},
{"id": 220, "subject": "M8-D2: #16 — real stale-instance enumeration in ImportResult", "status": "pending", "blockedBy": [219]},
{"id": 221, "subject": "M8-D3: Import plumbing — name-map through command, ManagementActor, CLI", "status": "pending", "blockedBy": [219, 216]},
{"id": 222, "subject": "M8-E1: Export wizard — Sites/Instances selection", "status": "pending", "blockedBy": [216]},
{"id": 223, "subject": "M8-E2: Import wizard — Map step + Modified +/- diff render", "status": "pending", "blockedBy": [221]},
{"id": 224, "subject": "M8-INT: Docs, full build, docker rebuild, Playwright, live smoke, end-to-end trace", "status": "pending", "blockedBy": [222, 223]}
],
"lastUpdated": "2026-06-18"
}
+8 -2
View File
@@ -325,11 +325,17 @@ scadabridge api-method delete --id <id>
### Bundle Commands (Transport #24)
```
scadabridge bundle export --output <path> [--passphrase <phrase>] [--all] [--include-dependencies] [--templates <names>] [--shared-scripts <names>] [--external-systems <names>] [--db-connections <names>] [--notification-lists <names>] [--smtp-configs <names>] [--api-methods <names>] [--source-environment <env>]
scadabridge bundle export --output <path> [--passphrase <phrase>] [--all] [--include-dependencies] [--templates <names>] [--shared-scripts <names>] [--external-systems <names>] [--db-connections <names>] [--notification-lists <names>] [--smtp-configs <names>] [--api-methods <names>] [--sites <ids|names>] [--instances <unique-names>] [--source-environment <env>]
scadabridge bundle preview --input <path> [--passphrase <phrase>]
scadabridge bundle import --input <path> [--passphrase <phrase>] [--on-conflict skip|overwrite|rename]
scadabridge bundle import --input <path> [--passphrase <phrase>] [--on-conflict skip|overwrite|rename] [--map-site <src=dst> ...] [--map-connection <srcSite/srcName=dstName> ...] [--create-missing-sites] [--create-missing-connections]
```
`--sites` / `--instances` (M8/T18) pull site-scoped artifacts: a `--sites` token is a `SiteIdentifier` (preferred) or friendly name; a `--instances` token is a `UniqueName`. Selecting an instance also pulls its site and bound `DataConnection`s.
`bundle preview` prints the per-row diff plus a **required-mapping summary**: for each source site/connection it states either the auto-match (`auto-matches 'X'`) or the directive to supply `--map-site` / `--map-connection` or `--create-missing-*`.
On import, the mapping flags reconcile environment-specific identifiers. `--map-site` / `--map-connection` are repeatable; a token with no `=dst` (or `=` with an empty right-hand side) means **create-new** from the bundle payload, otherwise it binds the source to the named existing target. `--create-missing-sites` / `--create-missing-connections` create any still-unmapped source site/connection instead of aborting. Imported instances always land `NotDeployed`.
Inbound API keys are not transported between environments — re-create them on the destination via CLI or UI.
Bundle commands use a 5-minute timeout.
+97 -30
View File
@@ -2,7 +2,9 @@
## Purpose
The Transport component provides a file-based, encrypted, environment-agnostic way to promote configuration artifacts from one ScadaBridge cluster to another through the Central UI. A user with the Design role on the source cluster exports a selected set of templates and supporting artifacts to a `.scadabundle` file. A user with the Admin role on the target cluster uploads the bundle, reviews a diff, resolves conflicts per artifact, and applies it. Import is config-only: it updates the central configuration database; affected instances surface as stale on the existing Deployments page and the user redeploys via the standard flow. Transport does not touch site nodes, does not move runtime state, and does not move site-scoped artifacts.
The Transport component provides a file-based, encrypted, environment-agnostic way to promote configuration artifacts from one ScadaBridge cluster to another through the Central UI. A user with the Design role on the source cluster exports a selected set of templates and supporting artifacts to a `.scadabundle` file. A user with the Admin role on the target cluster uploads the bundle, reviews a diff, resolves conflicts per artifact, and applies it. Import is config-only: it updates the central configuration database; affected instances surface as stale on the existing Deployments page and the user redeploys via the standard flow. Transport does not touch site runtime nodes and does not move runtime state.
As of M8 (T18), Transport is no longer limited to central-only configuration: it also moves **site-scoped configuration** — `Site` definitions, site-scoped `DataConnection`s (protocol connections), and `Instance`s with their override children and area membership. Because site identifiers and connection names differ across environments, a **name-mapping subsystem** (see "Name Mapping") reconciles source references onto target sites/connections at import time. This is still purely a central-configuration-database operation: Transport never deploys to or otherwise touches site cluster nodes — newly imported instances land as `NotDeployed`, and the operator redeploys via the existing pipeline.
## Location
@@ -16,6 +18,9 @@ The Transport component provides a file-based, encrypted, environment-agnostic w
- Define and own the `.scadabundle` file format (ZIP container, `manifest.json`, `content.json` / `content.enc`).
- Resolve artifact dependencies at export time: base templates, shared scripts, external systems, template folders, notification lists, SMTP configs, API keys, API methods.
- Move **site-scoped configuration** (T18): `Site` definitions, site-scoped `DataConnection`s (protocol connections — distinct from External-System `DatabaseConnection`s), and `Instance`s along with their `InstanceAttributeOverride` / `InstanceAlarmOverride` / `InstanceNativeAlarmSourceOverride` / `InstanceConnectionBinding` children and `Area` membership (carried by name).
- Reconcile cross-environment site identifiers and connection names through the **name-mapping subsystem** (`BundleNameMap`): auto-match by identifier/name, operator override via the import-wizard Map step or CLI flags, and per-conflict create-or-bind resolution (see "Name Mapping").
- Compute a **per-line (Myers) diff** for code fields on Modified artifacts (T20) via the pure `LineDiffer`, embedding a size-capped structured line diff in each `ArtifactDiff`.
- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`.
- Encrypt content with AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) when a passphrase is supplied; leave content plaintext with a UI warning and an `UnencryptedBundleExport` audit event when none is given.
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
@@ -49,7 +54,7 @@ Exactly one of `content.json` or `content.enc` is present.
```json
{
"bundleFormatVersion": 1,
"schemaVersion": "1.0",
"schemaVersion": "1.1",
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
@@ -65,13 +70,19 @@ Exactly one of `content.json` or `content.enc` is present.
"summary": {
"templates": 12, "templateFolders": 3, "sharedScripts": 4,
"externalSystems": 2, "dbConnections": 1,
"notificationLists": 1, "smtpConfigs": 0, "apiKeys": 2, "apiMethods": 5
"notificationLists": 1, "smtpConfigs": 0, "apiKeys": 2, "apiMethods": 5,
"sites": 2, "dataConnections": 3, "instances": 8
},
"contents": [
{ "type": "Template", "name": "Pump", "version": 5,
"dependsOn": ["SharedScript:PumpUtils"] },
{ "type": "Template", "name": "Pump.WaterPump", "version": 3,
"dependsOn": ["Template:Pump", "ExternalSystem:HistorianAPI"] }
"dependsOn": ["Template:Pump", "ExternalSystem:HistorianAPI"] },
{ "type": "Site", "name": "site-a", "version": 1, "dependsOn": [] },
{ "type": "DataConnection", "name": "site-a/PlantOpcUa", "version": 1,
"dependsOn": ["Site:site-a"] },
{ "type": "Instance", "name": "WaterPump-01", "version": 1,
"dependsOn": ["Template:Pump.WaterPump", "Site:site-a"] }
]
}
```
@@ -80,8 +91,8 @@ The manifest is plaintext so the import wizard can preview bundle contents and s
### `content.json` / `content.enc`
- One top-level array per entity type, POCO shapes serialized via `System.Text.Json`.
- Secret fields (API key hashes, SMTP password, external system credentials, DB connection passwords) live in a nested `secrets` block on each affected entity.
- One top-level array per entity type, POCO shapes serialized via `System.Text.Json`. As of schema 1.1 (T18) this includes `sites`, `dataConnections`, and `instances` arrays (`SiteDto`, `DataConnectionDto`, `InstanceDto` + its override-child and connection-binding DTOs) alongside the existing central-config arrays.
- Secret fields (API key hashes, SMTP password, external system credentials, DB connection passwords) live in a nested `secrets` block on each affected entity. Per the D3 "carry full config (encrypted secrets)" decision, a `DataConnection`'s `PrimaryConfiguration` / `BackupConfiguration` (endpoint + credentials) also ride inside this encrypted `SecretsBlock`; they appear in diffs as presence-only and are never echoed in plaintext. A `Site`'s `NodeA`/`NodeB` and `GrpcNodeA`/`GrpcNodeB` addresses travel as ordinary (non-secret) `SiteDto` fields.
- The whole `content` blob is AES-256-GCM encrypted with a key derived via PBKDF2-SHA256 (600 000 iterations) from the user's passphrase, with per-bundle random salt and per-encryption random IV.
- Unencrypted bundles are allowed but the UI warns and audit-tags them `UnencryptedBundleExport`.
@@ -89,6 +100,7 @@ The manifest is plaintext so the import wizard can preview bundle contents and s
- Unknown top-level entity types in `contents[]` surface in the import preview as "skipped — unsupported in this version" rather than failing the whole import.
- `bundleFormatVersion` newer than what the importer supports produces a hard refusal at upload with a clear upgrade message.
- `schemaVersion` minor increments are **additive-only**. T18 bumped it `1.0 → 1.1` (adding the `sites`/`dataConnections`/`instances` arrays). `bundleFormatVersion` stays `1`; `ManifestValidator` gates solely on `bundleFormatVersion`, so a pre-T18 importer still accepts a 1.1 bundle and simply lists the new site/instance/connection entries as "skipped — unsupported in this version".
## Architecture
@@ -119,7 +131,7 @@ flowchart TD
class T muted
```
The component is central-only. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session.
The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session.
## Export Flow
@@ -127,6 +139,8 @@ The component is central-only. It is registered in `ZB.MOM.WW.ScadaBridge.Host`
**Step 1 — Select artifacts.** Templates are rendered as a tree matching the existing Templates page (the `TemplateFolderTree.razor` shared component, used in its new checkbox-selection mode). Tri-state checkboxes on folders (`☑` all, `☐` none, `▣` partial). Search filters the tree in place. Other artifact groups (shared scripts, external systems, notification lists, SMTP configs, API keys, API methods) are flat checkbox lists.
A **Sites & Instances** section (T18) adds a flat list of sites, each row expandable to its instances; selecting a site or individual instances pulls them (and their site-scoped `DataConnection`s) into the bundle. The wizard distinguishes operator-seeded selections from artifacts auto-included by dependency resolution (e.g., an instance's site and bound connections).
**Step 2 — Review dependencies.** The resolver expands the user's selection along these edges:
- `Template A` composes `Template B` → include `B`.
@@ -195,27 +209,29 @@ Authorization: `RequireDesign` on both the Razor page and `IBundleExporter.Expor
## Import Flow
### UI — 5-Step Wizard (Admin nav group)
### UI — 6-Step Wizard (Admin nav group)
**Step 1 — Upload.** Drag-and-drop or browse. On selection, the manifest is parsed and displayed (source env, exporter, timestamp, content count, SHA-256, encrypted yes/no). The manifest hash is validated against the `content` blob.
**Step 2 — Passphrase** (skipped if the bundle is unencrypted). 3-wrong-attempt lockout invalidates the session.
**Step 3 — Diff & resolve conflicts.** For each artifact in the bundle, compare to existing by name:
**Step 3 — Map** (T18; shown only when the bundle carries sites and/or site-scoped connections). For every source `Site` and every source `(site, connection)` the importer first auto-matches against the target environment (sites by `SiteIdentifier`, connections by the site-qualified `{SiteIdentifier}/{Name}` key). The operator confirms or overrides each row: **Map to existing** (bind to a target site/connection) or **Create new** (materialize it from the bundle payload). Any source reference that neither auto-matches nor is set to Create-new becomes a **blocker row** and Apply stays disabled until resolved. The Map step renders only the rows that still require a decision; fully auto-matched references pass through silently.
**Step 4 — Diff & resolve conflicts.** For each artifact in the bundle, compare to existing by name:
- **Identical** (field-by-field) → marked, auto-skipped, cannot be selected.
- **Modified** → shows `+/-/~` line diff. User picks Skip / Overwrite / Rename.
- **Modified** → shows a real per-line `+/-` diff for code fields (`TemplateScript.Code`, `SharedScript.Code`, `ApiMethod.Script`), rendered by the shared `LineDiffView` component from the structured `lineDiff` payload (T20). User picks Skip / Overwrite / Rename.
- **New** → marked `+ Add`. User can opt to skip individually.
Bulk "Apply to all" at the top (Overwrite / Skip / Rename), overridable per row. Summary line at the bottom: "5 add · 2 overwrite · 1 skip · 0 rename".
Bundle references that cannot be satisfied in either the bundle or the target DB (e.g., a template references a shared script that is neither in the bundle nor pre-existing) appear as **blocker rows** — Apply is disabled until they are resolved (typically by skipping the dependent artifact or re-exporting with dependencies).
Bundle references that cannot be satisfied in either the bundle or the target DB (e.g., a template references a shared script that is neither in the bundle nor pre-existing) appear as **blocker rows** — Apply is disabled until they are resolved (typically by skipping the dependent artifact or re-exporting with dependencies). Unmapped/unmatched sites and connections that the operator did not set to Create-new also surface as blocker rows here, mirroring the Step-3 Map decisions.
**Blocker-scan heuristic boundaries.** The scanner walks `TemplateScript.Code`, `TemplateAttribute.Value`, and `ApiMethod.Script` looking for top-level `Identifier(` or `Identifier.` tokens. To keep the heuristic usable on real script bodies it (a) skips identifiers preceded by `.` (member access — `obj.Method()` does not flag `Method`); (b) does NOT scan `TemplateAttribute.DataSourceReference` (an OPC UA address path, never script source); and (c) filters out a small `KnownNonReferenceNames` denylist of .NET stdlib types (`Convert`, `DateTimeOffset`, `ToString`, `Dispose`, `UtcNow`, …), ScadaBridge runtime API roots (`Notify`, `Database`, `ExternalSystem`, `Scripts`, `Instance`, `Parameters`, `Attributes`, `Route`, …), and common SQL keywords that appear inside string literals (`COUNT`, `SELECT`, `FROM`, …). Both the diff-step `DetectBlockersAsync` and the Apply-time `RunSemanticValidationAsync` Pass 1 share this filter, so the diff preview and the Apply gate agree.
**Step 4 — Confirm.** Final summary plus a "N instances will become stale" warning enumerating affected instances. User types the source environment name to confirm (typo-resistant gate at the prod boundary).
**Step 5 — Confirm.** Final summary plus a "N instances will become stale" warning enumerating affected instances (a real count as of M8 — see "Stale-Instance Signaling"). User types the source environment name to confirm (typo-resistant gate at the prod boundary).
**Step 5 — Result.** Counts, link to the `BundleImported` audit row, link to the Deployments page filtered to the newly stale instances.
**Step 6 — Result.** Counts, link to the `BundleImported` audit row, link to the Deployments page filtered to the newly stale instances.
### Backend
@@ -226,8 +242,8 @@ flowchart TD
LOAD["IBundleImporter.LoadAsync<br/>· verify SHA-256 (manifest vs content)<br/>· check bundleFormatVersion supported<br/>· decrypt content.enc with passphrase (if encrypted)<br/>· deserialize entities<br/>· open BundleSession (30-min TTL)"]
PREVIEW["PreviewAsync → diff vs target DB → ImportPreview"]
REVIEW["(user reviews + resolves conflicts)"]
APPLY["ApplyAsync (single EF transaction)<br/>· run two-tier semantic validation<br/>&nbsp;&nbsp;(minimal name scan + full SemanticValidator)<br/>· apply resolutions (add / overwrite / skip / rename)<br/>· upsert TemplateFolder hierarchy<br/>· IAuditService.LogAsync(BundleImported …)<br/>· commit"]
RESULT["ImportResult → UI step 5"]
APPLY["ApplyAsync(resolutions, BundleNameMap?) — single EF transaction<br/>· run two-tier semantic validation<br/>&nbsp;&nbsp;(minimal name scan + full SemanticValidator)<br/>· resolve/create target sites + connections (name map)<br/>· apply resolutions (add / overwrite / skip / rename)<br/>· upsert TemplateFolder hierarchy<br/>· upsert instances (forced NotDeployed); rewire FKs<br/>· IAuditService.LogAsync(BundleImported …)<br/>· commit"]
RESULT["ImportResult → UI step 6"]
DEPLOYMENTS["'View on Deployments →' (existing page)"]
USER --> LOAD
@@ -253,9 +269,31 @@ flowchart TD
Authorization: `RequireAdmin` on both the Razor page and `IBundleImporter.*` entrypoints.
### Name Mapping
Site identifiers and connection names are environment-specific, so a bundle exported from `dev-cluster-a` cannot assume those identifiers exist (or mean the same thing) on the target. The **name-mapping subsystem** reconciles source → target references at import time.
**Model (`ZB.MOM.WW.ScadaBridge.Commons`).** `BundleNameMap` carries two collections:
- `SiteMapping(SourceSiteIdentifier, Action, TargetSiteIdentifier?)` — per source site.
- `ConnectionMapping(SourceSiteIdentifier, SourceConnectionName, Action, TargetConnectionName?)` — per source `(site, connection)`.
`Action` is the `MappingAction` enum: **`MapToExisting`** (bind the source reference to a named target site/connection, honouring the chosen conflict resolution) or **`CreateNew`** (materialize the site/connection from the bundle payload — full config, including the encrypted `DataConnection` secrets per D3). `BundleNameMap.Empty` is the no-mapping default for central-only bundles.
**Identity keys.** Sites are matched by `SiteIdentifier`; connections by the **site-qualified** key `{SiteIdentifier}/{Name}`; instances by `UniqueName`.
**Auto-match → operator override → apply.** The importer first auto-matches every source site and connection against the target by these identity keys. The operator confirms/overrides via the Central UI **Map step** (Step 3) or the CLI `--map-site` / `--map-connection` / `--create-missing-*` flags. References that neither auto-match nor are set to Create-new become **blocker rows** (UI) — or, on the CLI without `--create-missing-sites` / `--create-missing-connections`, abort before apply.
**FK rewiring at apply.** Inside the single EF transaction the importer resolves or creates the target sites and connections **first**, then upserts instances (forced to `NotDeployed`) and rewires their foreign keys against the resolved targets:
- `InstanceConnectionBinding.DataConnectionId` is resolved via the connection map, with a **Pass-2** that also binds connections which already exist in the target but were not carried in the bundle.
- `InstanceNativeAlarmSourceOverride.ConnectionNameOverride` is rewritten to the **mapped target** connection name.
### Stale-Instance Signaling
There is no explicit stale-mark write. Overwriting a template during import changes its content, which naturally changes the flattened-config hash that `DeploymentService.CompareAsync` computes at query time. The Deployments page already surfaces any instance whose computed hash differs from the deployed snapshot — no new mechanism is required. The import result enumerates affected instances (by computing the hash drift before committing) so Step 4 can display a preview count and Step 5 can link directly to the Deployments page.
There is no explicit stale-mark write. Overwriting a template during import changes its content, which naturally changes the flattened-config hash that `DeploymentService.CompareAsync` computes at query time. The Deployments page already surfaces any instance whose computed hash differs from the deployed snapshot — no new mechanism is required.
As of M8 the import result enumerates affected instances for real (closing the prior `StaleInstanceIds` stub). Before commit, for each **Overwritten** template the importer walks the existing **deployed** target instances of that template and, via the `IStaleInstanceProbe` seam in Commons (implemented in the Deployment Manager over the flattening pipeline), compares each instance's freshly-flattened revision hash against its `DeployedConfigSnapshot.RevisionHash`; instances whose hash has drifted are returned in `ImportResult.StaleInstanceIds`. The probe is optional — when it is not registered the result is an empty set (informational only, never blocking). Freshly-imported `NotDeployed` instances are surfaced as **new**, not stale. Step 5 (Confirm) shows the real count and Step 6 (Result) deep-links the Deployments page filtered to those instances.
## Error Handling
@@ -265,10 +303,12 @@ There is no explicit stale-mark write. Overwriting a template during import chan
| Upload | `bundleFormatVersion` newer than supported | Step 1 error: "Bundle was created by ScadaBridge v{x}; upgrade this cluster" |
| Upload | Content hash mismatch | Step 1 error: "Bundle integrity check failed — file may be corrupt" |
| Unlock | Wrong passphrase | Step 2 error; 3rd wrong attempt invalidates session, audit `BundleImportUnlockFailed` |
| Preview | Bundle references shared script not in bundle and not in target DB | Listed as a blocker row in Step 3; cannot Apply until resolved |
| Map | Source site/connection neither auto-matched nor mapped/created | Listed as a blocker row in the Map step (UI) / Diff step; cannot Apply until mapped or set to Create-new. CLI aborts before apply unless `--create-missing-sites` / `--create-missing-connections` is given |
| Map | `CreateNew` selected but the bundle does not carry that site/connection's config | Blocker — Create-new requires the full config to be present in the bundle payload |
| Preview | Bundle references shared script not in bundle and not in target DB | Listed as a blocker row in the Diff step; cannot Apply until resolved |
| Apply | Semantic validation fails (call target type mismatch, etc.) | Modal: "Validation failed — N errors", per-error list, no DB writes |
| Apply | DB transaction fails | Rollback; full transactional guarantee — nothing partial lands; `BundleImportFailed` audit row written outside the rolled-back transaction |
| Session | TTL expired | Step 3+ refresh prompts re-upload |
| Session | TTL expired | Diff step onward, refresh prompts re-upload |
Imports are all-or-nothing per bundle. A bundle either applies fully or not at all.
@@ -316,17 +356,22 @@ Three commands surface the same Transport operations as the Central UI wizards,
scadabridge bundle export --output FILE --passphrase X [--all | --templates A,B ...] \
[--shared-scripts ...] [--external-systems ...] [--db-connections ...] \
[--notification-lists ...] [--smtp-configs ...] [--api-keys ...] \
[--api-methods ...] [--include-dependencies] [--source-environment NAME]
[--api-methods ...] [--sites A,B ...] [--instances X,Y ...] \
[--include-dependencies] [--source-environment NAME]
scadabridge bundle preview --input FILE --passphrase X
# prints PreviewBundleResult JSON: per-row items + add/modified/identical/blocker counts
# prints PreviewBundleResult JSON: per-row items + add/modified/identical/blocker counts;
# also prints a required-mapping summary — for each source site/connection it states the
# auto-match ("auto-matches 'X'") or the directive ("use --map-site / --create-missing-sites")
scadabridge bundle import --input FILE --passphrase X [--on-conflict skip|overwrite|rename]
scadabridge bundle import --input FILE --passphrase X [--on-conflict skip|overwrite|rename] \
[--map-site src=dst ...] [--map-connection srcSite/srcName=dstName ...] \
[--create-missing-sites] [--create-missing-connections]
# one-shot load + preview + apply with a single global policy for Modified rows
# Identical → Skip, New → Add, Blocker → abort
```
Selection uses entity **names** rather than IDs so scripts are portable across environments. The CLI per-command timeout is 5 minutes (vs the default 30 s for other commands) to comfortably cover large bundles. CLI commands route through `ManagementActor`'s `ExportBundleCommand` / `PreviewBundleCommand` / `ImportBundleCommand` handlers, which delegate to the same `IBundleExporter` / `IBundleImporter` services as the UI.
Selection uses entity **names** rather than IDs so scripts are portable across environments — `--sites` accepts a `SiteIdentifier` (preferred) or friendly name per token, `--instances` accepts a `UniqueName`. The mapping flags are repeatable; a `--map-site`/`--map-connection` token with no `=dst` part (or `=` with an empty right-hand side) means **Create-new**, otherwise it binds to the named existing target. `--create-missing-sites` / `--create-missing-connections` create any still-unmapped source site/connection from the bundle payload instead of aborting. The CLI per-command timeout is 5 minutes (vs the default 30 s for other commands) to comfortably cover large bundles. CLI commands route through `ManagementActor`'s `ExportBundleCommand` / `PreviewBundleCommand` / `ImportBundleCommand` handlers, which delegate to the same `IBundleExporter` / `IBundleImporter` services as the UI.
Exit codes follow the project convention: `0` = success, `1` = command failure (validation, blockers, wrong passphrase), `2` = authorization failure.
@@ -342,8 +387,9 @@ Exit codes follow the project convention: `0` = success, `1` = command failure (
## Dependencies
- **`ZB.MOM.WW.ScadaBridge.Commons`** — Bundle manifest and content DTOs (`BundleManifest`, `ExportSelection`, `ImportPreview`, `ImportResolution`, `ImportResult`, `BundleSession`); transport interface definitions (`IBundleExporter`, `IBundleImporter`, `IBundleSessionStore`, `IAuditCorrelationContext`).
- **`ZB.MOM.WW.ScadaBridge.ConfigurationDatabase`** — All repository implementations and `IAuditService` for persistence and per-entity audit emission; `IAuditCorrelationContext` implementation (`AuditCorrelationContext`) registered as a scoped service; EF migration for `BundleImportId`.
- **`ZB.MOM.WW.ScadaBridge.Commons`** — Bundle manifest and content DTOs (`BundleManifest`, `ExportSelection`, `ImportPreview`, `ImportResolution`, `ImportResult`, `BundleSession`); the name-mapping model (`BundleNameMap`, `SiteMapping`, `ConnectionMapping`, `MappingAction`); transport interface definitions (`IBundleExporter`, `IBundleImporter`, `IBundleSessionStore`, `IAuditCorrelationContext`, `IStaleInstanceProbe`).
- **`ZB.MOM.WW.ScadaBridge.ConfigurationDatabase`** — All repository implementations and `IAuditService` for persistence and per-entity audit emission; `IAuditCorrelationContext` implementation (`AuditCorrelationContext`) registered as a scoped service; EF migration for `BundleImportId`. Site/`DataConnection`/`Instance` transport (T18) reuses the **existing** entities — no new tables or columns.
- **`ZB.MOM.WW.ScadaBridge.DeploymentManager`** — `IStaleInstanceProbe` implementation (`StaleInstanceProbe`) over the flattening pipeline, supplying the real `ImportResult.StaleInstanceIds` enumeration (optional DI registration; absent → empty, informational-only result).
- **`ZB.MOM.WW.ScadaBridge.TemplateEngine`** — Pre-deployment `SemanticValidator` invoked inside `ApplyAsync` before the transaction commits. The importer builds a single-template `FlattenedConfiguration` directly from each imported `TemplateDto` (no inheritance / composition resolution at design time — the deployment-time flatten revalidates against the full instance graph) and feeds it through the validator alongside a `ResolvedScript` catalog combining in-bundle + pre-existing target `SharedScript`s. Validator errors are aggregated per template and surfaced as a `SemanticValidationException` that rolls back the import transaction.
## Interactions
@@ -363,7 +409,7 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
```json
{
"bundleFormatVersion": 1,
"schemaVersion": "1.0",
"schemaVersion": "1.1",
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
@@ -385,7 +431,10 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
"notificationLists": 1,
"smtpConfigs": 0,
"apiKeys": 2,
"apiMethods": 5
"apiMethods": 5,
"sites": 2,
"dataConnections": 3,
"instances": 8
},
"contents": [
{
@@ -399,6 +448,24 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
"name": "Pump.WaterPump",
"version": 3,
"dependsOn": ["Template:Pump", "ExternalSystem:HistorianAPI"]
},
{
"type": "Site",
"name": "site-a",
"version": 1,
"dependsOn": []
},
{
"type": "DataConnection",
"name": "site-a/PlantOpcUa",
"version": 1,
"dependsOn": ["Site:site-a"]
},
{
"type": "Instance",
"name": "WaterPump-01",
"version": 1,
"dependsOn": ["Template:Pump.WaterPump", "Site:site-a"]
}
]
}
@@ -408,8 +475,8 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
| Field | Description |
|---|---|
| `bundleFormatVersion` | Integer. Importer hard-refuses any value higher than what its `TransportOptions.SchemaVersionMajor` supports. |
| `schemaVersion` | Semver string. Minor increments are additive-only and accepted by older importers. |
| `bundleFormatVersion` | Integer. Importer hard-refuses any value higher than what its `TransportOptions.SchemaVersionMajor` supports. Stays `1` through T18. |
| `schemaVersion` | Semver string. Minor increments are additive-only and accepted by older importers. T18 bumped this `1.0 → 1.1` (adding the `sites`/`dataConnections`/`instances` content arrays); a pre-T18 importer lists those new entries as "skipped — unsupported in this version" since `ManifestValidator` gates only on `bundleFormatVersion`. |
| `createdAtUtc` | ISO-8601 UTC timestamp of when the export was created. |
| `sourceEnvironment` | The `SourceEnvironment` name of the exporting cluster (from `TransportOptions`). Displayed in the import wizard and required to be retyped at the confirm step. |
| `exportedBy` | Authenticated username of the person who performed the export. |
@@ -421,5 +488,5 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
| `encryption.iterations` | PBKDF2 iteration count. Defaults to 600 000 (configurable via `TransportOptions.Pbkdf2Iterations`). |
| `encryption.saltB64` | Base64-encoded 16-byte random salt generated at export time. |
| `encryption.ivB64` | Base64-encoded 12-byte (GCM standard) random IV generated at export time. |
| `summary` | Artifact count by type, for display in the import wizard's upload step without needing to decrypt content. |
| `contents` | Ordered list of all artifacts in the bundle. Order is topological (base templates before derived). Each entry carries the artifact's name, the schema `version` at export time, and its direct `dependsOn` edges for dependency display in the export wizard's Step 2. |
| `summary` | Artifact count by type, for display in the import wizard's upload step without needing to decrypt content. Schema 1.1 adds `sites` / `dataConnections` / `instances` counts. |
| `contents` | Ordered list of all artifacts in the bundle. Order is topological (base templates before derived; sites before their connections and instances). Each entry carries the artifact's name (connections use the site-qualified `{SiteIdentifier}/{Name}`), the schema `version` at export time, and its direct `dependsOn` edges for dependency display in the export wizard's Step 2. T18 adds the `Site`, `DataConnection`, and `Instance` entry `type`s. |
@@ -65,6 +65,10 @@ public static class BundleCommands
// --api-keys option. Re-create keys and re-grant their method scopes on the
// destination via the admin UI/CLI.
var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names");
// M8 (B4): site/instance-scoped export. Sites accept a SiteIdentifier
// (preferred) or friendly Name per token; instances accept a UniqueName.
var sitesOption = NameListOption("--sites", "Comma-separated site identifiers or names");
var instancesOption = NameListOption("--instances", "Comma-separated instance unique-names");
var includeDepsOption = new Option<bool>("--include-dependencies")
{
Description = "Pull transitive dependencies (referenced shared scripts, parents, composed members) into the bundle.",
@@ -88,6 +92,8 @@ public static class BundleCommands
cmd.Add(notificationListsOption);
cmd.Add(smtpConfigsOption);
cmd.Add(apiMethodsOption);
cmd.Add(sitesOption);
cmd.Add(instancesOption);
cmd.Add(includeDepsOption);
cmd.Add(sourceEnvOption);
@@ -110,7 +116,9 @@ public static class BundleCommands
ApiMethodNames: result.GetValue(apiMethodsOption),
IncludeDependencies: includeDeps,
Passphrase: passphrase,
SourceEnvironment: sourceEnv);
SourceEnvironment: sourceEnv,
SiteNames: result.GetValue(sitesOption),
InstanceNames: result.GetValue(instancesOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
@@ -214,6 +222,11 @@ public static class BundleCommands
timeout: BundleCommandTimeout,
onSuccess: jsonOk =>
{
// Surface the required site/connection mappings so an operator
// sees which references need resolving on `bundle import`. The
// full PreviewBundleResult JSON (including the new fields) is
// still printed verbatim afterwards for tooling/jq.
PrintRequiredMappingSummary(jsonOk);
Console.WriteLine(jsonOk);
return 0;
});
@@ -221,6 +234,60 @@ public static class BundleCommands
return cmd;
}
/// <summary>
/// Parses the <see cref="PreviewBundleResult"/> JSON envelope and writes a
/// human-readable summary of the <c>requiredSiteMappings</c> /
/// <c>requiredConnectionMappings</c> arrays to stdout. Best-effort: any parse
/// hiccup is swallowed so the raw JSON (printed by the caller) remains the
/// authoritative output.
/// </summary>
/// <param name="jsonOk">The success-body JSON returned by the management endpoint.</param>
private static void PrintRequiredMappingSummary(string jsonOk)
{
try
{
using var doc = JsonDocument.Parse(jsonOk);
var root = doc.RootElement;
var sites = root.TryGetProperty("requiredSiteMappings", out var s)
&& s.ValueKind == JsonValueKind.Array ? s : default;
var conns = root.TryGetProperty("requiredConnectionMappings", out var c)
&& c.ValueKind == JsonValueKind.Array ? c : default;
var siteCount = sites.ValueKind == JsonValueKind.Array ? sites.GetArrayLength() : 0;
var connCount = conns.ValueKind == JsonValueKind.Array ? conns.GetArrayLength() : 0;
if (siteCount == 0 && connCount == 0) return;
Console.WriteLine("Mappings required before import:");
foreach (var site in sites.EnumerateArray())
{
var src = site.TryGetProperty("sourceSiteIdentifier", out var v) ? v.GetString() : null;
var name = site.TryGetProperty("sourceSiteName", out var n) ? n.GetString() : null;
var auto = site.TryGetProperty("autoMatchTargetIdentifier", out var a)
&& a.ValueKind == JsonValueKind.String ? a.GetString() : null;
var hint = auto is null
? "no auto-match (use --map-site or --create-missing-sites)"
: $"auto-matches '{auto}'";
Console.WriteLine($" site: {src} ({name}) — {hint}");
}
foreach (var conn in conns.EnumerateArray())
{
var src = conn.TryGetProperty("sourceSiteIdentifier", out var v) ? v.GetString() : null;
var cname = conn.TryGetProperty("sourceConnectionName", out var n) ? n.GetString() : null;
var auto = conn.TryGetProperty("autoMatchTargetName", out var a)
&& a.ValueKind == JsonValueKind.String ? a.GetString() : null;
var hint = auto is null
? "no auto-match (use --map-connection or --create-missing-connections)"
: $"auto-matches '{auto}'";
Console.WriteLine($" connection: {src}/{cname} — {hint}");
}
Console.WriteLine();
}
catch (JsonException)
{
// Best-effort: the raw JSON is still printed by the caller.
}
}
// ====================================================================
// bundle import
// ====================================================================
@@ -242,6 +309,30 @@ public static class BundleCommands
Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.",
DefaultValueFactory = _ => "overwrite",
};
// M8 (D3): site/connection name mapping. Repeatable. A token with no
// "=dst" part, or "=" with an empty right-hand side, means CreateNew (the
// destination is created from the bundle payload); otherwise the source
// is mapped to the named existing destination.
var mapSiteOption = new Option<string[]>("--map-site")
{
Description = "Map a source site to a destination: 'srcIdentifier=dstIdentifier'. " +
"'srcIdentifier' or 'srcIdentifier=' (no/empty dst) means create-new. Repeatable.",
AllowMultipleArgumentsPerToken = true,
};
var mapConnectionOption = new Option<string[]>("--map-connection")
{
Description = "Map a source connection to a destination: 'srcSiteIdentifier/srcName=dstName'. " +
"'srcSiteIdentifier/srcName' or '…=' (no/empty dst) means create-new. Repeatable.",
AllowMultipleArgumentsPerToken = true,
};
var createMissingSitesOption = new Option<bool>("--create-missing-sites")
{
Description = "Create any unmapped/unmatched source site from the bundle payload instead of failing.",
};
var createMissingConnectionsOption = new Option<bool>("--create-missing-connections")
{
Description = "Create any unmapped/unmatched source connection from the bundle payload instead of failing.",
};
var cmd = new Command("import")
{
@@ -250,6 +341,10 @@ public static class BundleCommands
cmd.Add(inputOption);
cmd.Add(passphraseOption);
cmd.Add(onConflictOption);
cmd.Add(mapSiteOption);
cmd.Add(mapConnectionOption);
cmd.Add(createMissingSitesOption);
cmd.Add(createMissingConnectionsOption);
cmd.SetAction(async (ParseResult result) =>
{
@@ -259,11 +354,29 @@ public static class BundleCommands
OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND");
return 1;
}
List<SiteMappingSpec> siteMappings;
List<ConnectionMappingSpec> connectionMappings;
try
{
siteMappings = ParseSiteMappings(result.GetValue(mapSiteOption));
connectionMappings = ParseConnectionMappings(result.GetValue(mapConnectionOption));
}
catch (FormatException ex)
{
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
return 1;
}
var bytes = await File.ReadAllBytesAsync(input);
var payload = new ImportBundleCommand(
Base64Bundle: Convert.ToBase64String(bytes),
Passphrase: result.GetValue(passphraseOption),
DefaultConflictPolicy: result.GetValue(onConflictOption)!);
DefaultConflictPolicy: result.GetValue(onConflictOption)!,
SiteMappings: siteMappings.Count == 0 ? null : siteMappings,
ConnectionMappings: connectionMappings.Count == 0 ? null : connectionMappings,
CreateMissingSites: result.GetValue(createMissingSitesOption),
CreateMissingConnections: result.GetValue(createMissingConnectionsOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
@@ -359,14 +472,104 @@ public static class BundleCommands
{
Description = description,
CustomParser = arg =>
{
var token = arg.Tokens.Count == 0 ? null : arg.Tokens[0].Value;
if (string.IsNullOrWhiteSpace(token)) return null;
return token
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
},
ParseNameList(arg.Tokens.Count == 0 ? null : arg.Tokens[0].Value),
};
return opt;
}
/// <summary>
/// Splits a comma-separated CLI value into a trimmed, empty-entry-free name
/// list (the shared shape used by every <c>--templates</c>/<c>--sites</c>/…
/// option). Returns <c>null</c> for a null/blank token so the management
/// command sees "not specified" rather than an empty list. Exposed
/// <c>internal</c> so the flag-parse tests can assert the split semantics
/// without reaching into the per-command local options.
/// </summary>
/// <param name="token">The raw comma-separated option value, or <c>null</c>.</param>
/// <returns>The parsed name array, or <c>null</c> when the token is null/blank.</returns>
internal static IReadOnlyList<string>? ParseNameList(string? token)
{
if (string.IsNullOrWhiteSpace(token)) return null;
return token
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
}
/// <summary>
/// Parses the repeatable <c>--map-site</c> tokens into
/// <see cref="SiteMappingSpec"/>s. Each token is
/// <c>srcIdentifier=dstIdentifier</c> (map to an existing destination) or
/// <c>srcIdentifier</c> / <c>srcIdentifier=</c> (no/empty right-hand side →
/// CreateNew, surfaced as a null target). Exposed <c>internal</c> so the
/// flag-parse tests can assert the split + CreateNew convention.
/// </summary>
/// <param name="tokens">Raw <c>--map-site</c> token values, or <c>null</c>.</param>
/// <returns>The parsed spec list (possibly empty).</returns>
/// <exception cref="FormatException">A token has an empty source identifier.</exception>
internal static List<SiteMappingSpec> ParseSiteMappings(string[]? tokens)
{
var result = new List<SiteMappingSpec>();
if (tokens is null) return result;
foreach (var token in tokens)
{
if (string.IsNullOrWhiteSpace(token)) continue;
var (left, right) = SplitOnFirst(token, '=');
var src = left.Trim();
if (src.Length == 0)
{
throw new FormatException(
$"Invalid --map-site '{token}': source identifier is required (use 'src=dst' or 'src' for create-new).");
}
// null target == CreateNew, per the documented convention.
result.Add(new SiteMappingSpec(src, string.IsNullOrWhiteSpace(right) ? null : right!.Trim()));
}
return result;
}
/// <summary>
/// Parses the repeatable <c>--map-connection</c> tokens into
/// <see cref="ConnectionMappingSpec"/>s. Each token is
/// <c>srcSiteIdentifier/srcName=dstName</c> (map to an existing destination)
/// or <c>srcSiteIdentifier/srcName</c> / <c>…=</c> (no/empty right-hand side →
/// CreateNew, surfaced as a null target). Exposed <c>internal</c> for the
/// flag-parse tests.
/// </summary>
/// <param name="tokens">Raw <c>--map-connection</c> token values, or <c>null</c>.</param>
/// <returns>The parsed spec list (possibly empty).</returns>
/// <exception cref="FormatException">A token is missing the <c>site/name</c> source shape.</exception>
internal static List<ConnectionMappingSpec> ParseConnectionMappings(string[]? tokens)
{
var result = new List<ConnectionMappingSpec>();
if (tokens is null) return result;
foreach (var token in tokens)
{
if (string.IsNullOrWhiteSpace(token)) continue;
var (lhs, right) = SplitOnFirst(token, '=');
var (site, name) = SplitOnFirst(lhs, '/');
var srcSite = site.Trim();
var srcName = (name ?? string.Empty).Trim();
if (srcSite.Length == 0 || name is null || srcName.Length == 0)
{
throw new FormatException(
$"Invalid --map-connection '{token}': expected 'srcSiteIdentifier/srcName[=dstName]'.");
}
// null target == CreateNew, per the documented convention.
result.Add(new ConnectionMappingSpec(srcSite, srcName, string.IsNullOrWhiteSpace(right) ? null : right!.Trim()));
}
return result;
}
/// <summary>
/// Splits <paramref name="value"/> on the first occurrence of
/// <paramref name="separator"/>. When the separator is absent the right side
/// is <c>null</c> (distinguishing "src" from "src=" — both mean CreateNew here,
/// but callers that need to detect the separator's presence can).
/// </summary>
private static (string Left, string? Right) SplitOnFirst(string value, char separator)
{
var idx = value.IndexOf(separator);
return idx < 0
? (value, null)
: (value[..idx], value[(idx + 1)..]);
}
}
+17 -5
View File
@@ -1667,8 +1667,10 @@ scadabridge --url <url> api-method delete --id <int>
Export, preview, and import Transport (#24) bundles. Bundles carry templates, shared
scripts, external systems, database connections, notification lists, SMTP configurations,
and API methods between environments. Inbound API keys are **not** transported — recreate
them on the destination via the CLI or UI.
and API methods between environments. As of M8 (T18) they also carry **site-scoped**
configuration — `Site` definitions, site-scoped `DataConnection`s (protocol connections),
and `Instance`s (with their override children and area membership). Inbound API keys are
**not** transported — recreate them on the destination via the CLI or UI.
Bundle commands use a 5-minute timeout (larger payloads may be slow over WAN).
@@ -1693,6 +1695,8 @@ scadabridge --url <url> bundle export --output <path> [--passphrase <string>] [-
| `--notification-lists` | no | Comma-separated notification-list names to include |
| `--smtp-configs` | no | Comma-separated SMTP host names to include |
| `--api-methods` | no | Comma-separated API-method names to include |
| `--sites` | no | Comma-separated site identifiers (preferred) or friendly names to include |
| `--instances` | no | Comma-separated instance unique-names to include (also pulls their site + bound data connections) |
| `--source-environment` | no | `SourceEnvironment` value stamped into the bundle manifest (default: `cli`) |
**Example** — export two templates and all their dependencies:
@@ -1704,7 +1708,7 @@ scadabridge --url <url> bundle export --output baseline.scadabundle \
#### `bundle preview`
Load a bundle and print the diff preview (Added / Modified / Unchanged per entity) without applying any changes.
Load a bundle and print the diff preview (Added / Modified / Unchanged per entity) without applying any changes. When the bundle carries sites or site-scoped connections, the preview also prints a **required-mapping summary**: for each source site/connection it states either the auto-match (`auto-matches 'X'`) or the directive to supply `--map-site` / `--map-connection` or `--create-missing-*`.
```sh
scadabridge --url <url> bundle preview --input <path> [--passphrase <string>]
@@ -1717,10 +1721,12 @@ scadabridge --url <url> bundle preview --input <path> [--passphrase <string>]
#### `bundle import`
Load and apply a bundle with a single global conflict policy. Preview first with `bundle preview` to review the diff.
Load and apply a bundle with a single global conflict policy. Preview first with `bundle preview` to review the diff and the required-mapping summary.
```sh
scadabridge --url <url> bundle import --input <path> [--passphrase <string>] [--on-conflict <policy>]
scadabridge --url <url> bundle import --input <path> [--passphrase <string>] [--on-conflict <policy>] \
[--map-site <src=dst> ...] [--map-connection <srcSite/srcName=dstName> ...] \
[--create-missing-sites] [--create-missing-connections]
```
| Option | Required | Default | Description |
@@ -1728,6 +1734,12 @@ scadabridge --url <url> bundle import --input <path> [--passphrase <string>] [--
| `--input` | yes | — | Bundle file path (`.scadabundle`) |
| `--passphrase` | no | — | Passphrase for encrypted bundles |
| `--on-conflict` | no | `overwrite` | Resolution policy for `Modified` rows: `skip`, `overwrite`, or `rename` |
| `--map-site` | no | — | Map a source site to a destination: `srcIdentifier=dstIdentifier`. A token with no/empty `=dst` means **create-new**. Repeatable. |
| `--map-connection` | no | — | Map a source connection to a destination: `srcSiteIdentifier/srcName=dstName`. A token with no/empty `=dst` means **create-new**. Repeatable. |
| `--create-missing-sites` | no | `false` | Create any still-unmapped/unmatched source site from the bundle payload instead of aborting |
| `--create-missing-connections` | no | `false` | Create any still-unmapped/unmatched source connection from the bundle payload instead of aborting |
Site identifiers and connection names are environment-specific; the importer first auto-matches them (sites by `SiteIdentifier`, connections by the site-qualified `{SiteIdentifier}/{Name}`). Any source reference that neither auto-matches nor is mapped/created aborts the import. Imported instances always land `NotDeployed` — redeploy via the normal flow.
---
@@ -149,6 +149,17 @@
</div>
</fieldset>
<fieldset class="mb-4" data-testid="group-sites">
<legend class="h6">Sites &amp; Instances</legend>
@RenderSitesList()
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="sites-hint">
Selecting a <strong>site</strong> includes its data connections and all of its
instances. Expand a site to pick individual <strong>instances</strong> instead.
Each instance pulls in its template (and that template's dependency closure)
when <em>Include all dependencies</em> is on.
</div>
</fieldset>
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-primary"
disabled="@(!HasAnySelection || _resolving)"
@@ -200,6 +211,80 @@
</div>
};
// Sites &amp; Instances picker (M8 E1): a flat site checkbox list, each row
// expandable to reveal its instances. The search box matches a site by its
// Name or SiteIdentifier, and an instance by its UniqueName; a site stays
// visible when any of its instances match so the operator can drill in.
private RenderFragment RenderSitesList() => __builder =>
{
var visibleSites = _sites
.Where(site => MatchesFilter(site.Name)
|| MatchesFilter(site.SiteIdentifier)
|| InstancesFor(site.Id).Any(i => MatchesFilter(i.UniqueName)))
.ToList();
if (visibleSites.Count == 0)
{
<div class="text-muted small fst-italic">@(_sites.Count == 0 ? "No sites." : "No matches.")</div>
return;
}
<div class="d-flex flex-column gap-1">
@foreach (var site in visibleSites)
{
var siteInputId = $"chk-site-{site.Id}";
var expanded = _expandedSites.Contains(site.Id);
var instances = InstancesFor(site.Id);
<div class="border rounded p-2" data-testid="site-row" data-site-id="@site.Id">
<div class="d-flex align-items-center gap-2">
@if (instances.Count > 0)
{
<button type="button" class="btn btn-sm btn-link p-0 text-decoration-none"
aria-expanded="@expanded.ToString().ToLowerInvariant()"
aria-label="@(expanded ? "Collapse" : "Expand") instances for @site.Name"
@onclick="() => ToggleSiteExpanded(site.Id)">
<span aria-hidden="true">@(expanded ? "▾" : "▸")</span>
</button>
}
else
{
<span class="text-muted" style="width: 1rem; display: inline-block;" aria-hidden="true"></span>
}
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox"
id="@siteInputId"
checked="@_selectedSites.Contains(site.Id)"
@onchange="e => ToggleSite(site.Id, ((bool?)e.Value) == true)" />
<label class="form-check-label" for="@siteInputId">
@site.Name <span class="text-muted small">(@site.SiteIdentifier)</span>
</label>
</div>
</div>
@if (expanded && instances.Count > 0)
{
<div class="ms-4 mt-2 d-flex flex-column gap-1" data-testid="site-instances">
@foreach (var instance in instances.Where(i => MatchesFilter(i.UniqueName)
|| MatchesFilter(site.Name) || MatchesFilter(site.SiteIdentifier)))
{
var instInputId = $"chk-instance-{instance.Id}";
var siteSelected = _selectedSites.Contains(site.Id);
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@instInputId"
disabled="@siteSelected"
checked="@(siteSelected || _selectedInstances.Contains(instance.Id))"
@onchange="e => Toggle(_selectedInstances, instance.Id, ((bool?)e.Value) == true)" />
<label class="form-check-label" for="@instInputId">@instance.UniqueName</label>
</div>
}
</div>
}
</div>
}
</div>
};
// ============================================================
// Step 2 — Review
// ============================================================
@@ -214,10 +299,17 @@
var seedTemplateIds = new HashSet<int>(SelectedTemplateIds());
var seedSharedScripts = new HashSet<int>(_selectedSharedScripts);
var seedExternalSystems = new HashSet<int>(_selectedExternalSystems);
// M8 (E1): sites/instances the operator ticked directly are "seed"; everything
// else the resolver pulled into Sites/Instances (e.g. an instance's owning site,
// or a site's instances) is auto-included.
var seedSiteIds = new HashSet<int>(_selectedSites);
var seedInstanceIds = new HashSet<int>(_selectedInstances);
var autoTemplates = AutoIncluded(_resolved.Templates, seedTemplateIds, t => t.Id);
var autoShared = AutoIncluded(_resolved.SharedScripts, seedSharedScripts, s => s.Id);
var autoExternals = AutoIncluded(_resolved.ExternalSystems, seedExternalSystems, e => e.Id);
var autoSites = AutoIncluded(_resolved.Sites, seedSiteIds, s => s.Id);
var autoInstances = AutoIncluded(_resolved.Instances, seedInstanceIds, i => i.Id);
<div>
<p class="text-body-secondary">
@@ -267,11 +359,21 @@
{
<li>ApiMethod: @m.Name</li>
}
@foreach (var s in _resolved.Sites.Where(s => seedSiteIds.Contains(s.Id)).OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase))
{
<li>Site: @s.Name (@s.SiteIdentifier)</li>
}
@foreach (var i in _resolved.Instances.Where(i => seedInstanceIds.Contains(i.Id)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase))
{
<li>Instance: @i.UniqueName</li>
}
</ul>
</div>
<div class="col-md-6" data-testid="auto-group">
<h6>Auto-included (dependencies)</h6>
@if (autoTemplates.Count + autoShared.Count + autoExternals.Count + _resolved.TemplateFolders.Count == 0)
@if (autoTemplates.Count + autoShared.Count + autoExternals.Count
+ autoSites.Count + autoInstances.Count + _resolved.DataConnections.Count
+ _resolved.TemplateFolders.Count == 0)
{
<div class="small text-muted fst-italic">No additional dependencies.</div>
}
@@ -294,6 +396,18 @@
{
<li>ExternalSystem: @e.Name</li>
}
@foreach (var s in autoSites.OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase))
{
<li>Site: @s.Name (@s.SiteIdentifier)</li>
}
@foreach (var i in autoInstances.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase))
{
<li>Instance: @i.UniqueName</li>
}
@foreach (var c in _resolved.DataConnections.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
{
<li>DataConnection: @c.Name</li>
}
</ul>
}
</div>
@@ -6,8 +6,10 @@ using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
@@ -51,6 +53,7 @@ public partial class TransportExport : ComponentBase
[Inject] private IExternalSystemRepository ExternalRepo { get; set; } = default!;
[Inject] private INotificationRepository NotificationRepo { get; set; } = default!;
[Inject] private IInboundApiRepository InboundApiRepo { get; set; } = default!;
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
[Inject] private DependencyResolver DepResolver { get; set; } = default!;
[Inject] private IJSRuntime JS { get; set; } = default!;
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
@@ -71,6 +74,11 @@ public partial class TransportExport : ComponentBase
private List<SmtpConfiguration> _smtpConfigs = new();
// Inbound API keys are not transported between environments (re-arch C4); only methods.
private List<ApiMethod> _apiMethods = new();
// M8 (E1): site/instance-scoped export. Sites are listed flat; each site's
// instances hang off it (loaded eagerly via ISiteRepository.GetInstancesBySiteIdAsync)
// so the operator can pick a whole site or drill into individual instances.
private List<Site> _sites = new();
private readonly Dictionary<int, List<Instance>> _instancesBySiteId = new();
// ---- Step 1: selection state ----
// TemplateFolderTree uses string keys ("t:{id}", "f:{id}") via TemplateTreeNode.Key.
@@ -84,6 +92,12 @@ public partial class TransportExport : ComponentBase
private readonly HashSet<int> _selectedSmtpConfigs = new();
// No _selectedApiKeys: inbound API keys are not transported (re-arch C4).
private readonly HashSet<int> _selectedApiMethods = new();
// M8 (E1): site/instance selection backed by entity primary keys, matching the
// DependencyResolver's SiteIds/InstanceIds (NOT names — the resolver fetches by id).
private readonly HashSet<int> _selectedSites = new();
private readonly HashSet<int> _selectedInstances = new();
// Which site rows are expanded to reveal their instance checkboxes (UI-only).
private readonly HashSet<int> _expandedSites = new();
private string _filter = string.Empty;
private bool _includeDependencies = true;
@@ -126,6 +140,22 @@ public partial class TransportExport : ComponentBase
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
// Inbound API keys are not transported (re-arch C4) — only methods are loaded.
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
// M8 (E1): sites + their instances for site/instance-scoped export. Each
// site's instances are loaded eagerly so the expandable picker has them
// without a per-click round-trip; sites are ordered by identifier to match
// the bundle's deterministic site ordering.
_sites = (await SiteRepo.GetAllSitesAsync())
.OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase)
.ToList();
_instancesBySiteId.Clear();
foreach (var site in _sites)
{
var instances = (await SiteRepo.GetInstancesBySiteIdAsync(site.Id))
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.ToList();
_instancesBySiteId[site.Id] = instances;
}
}
catch (Exception ex)
{
@@ -169,7 +199,9 @@ public partial class TransportExport : ComponentBase
|| _selectedDbConnections.Count > 0
|| _selectedNotificationLists.Count > 0
|| _selectedSmtpConfigs.Count > 0
|| _selectedApiMethods.Count > 0;
|| _selectedApiMethods.Count > 0
|| _selectedSites.Count > 0
|| _selectedInstances.Count > 0;
private bool PassphraseValid =>
!string.IsNullOrEmpty(_passphrase)
@@ -207,7 +239,15 @@ public partial class TransportExport : ComponentBase
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
// Inbound API keys are not transported (re-arch C4) — methods only.
ApiMethodIds: _selectedApiMethods.ToList(),
IncludeDependencies: _includeDependencies);
IncludeDependencies: _includeDependencies)
{
// M8 (E1): site/instance ids feed the resolver's SiteIds/InstanceIds.
// Set via init-only properties so the positional ctor stays the documented
// additive shape; the resolver dedups a selected instance against its
// already-selected owning site.
SiteIds = _selectedSites.ToList(),
InstanceIds = _selectedInstances.ToList(),
};
}
private async Task GoToReviewAsync()
@@ -278,6 +318,11 @@ public partial class TransportExport : ComponentBase
{
if (!string.IsNullOrEmpty(db.ConnectionString)) count++;
}
foreach (var dc in resolved.DataConnections)
{
if (!string.IsNullOrEmpty(dc.PrimaryConfiguration)) count++;
if (!string.IsNullOrEmpty(dc.BackupConfiguration)) count++;
}
return count;
}
@@ -396,6 +441,9 @@ public partial class TransportExport : ComponentBase
_selectedNotificationLists.Clear();
_selectedSmtpConfigs.Clear();
_selectedApiMethods.Clear();
_selectedSites.Clear();
_selectedInstances.Clear();
_expandedSites.Clear();
_filter = string.Empty;
_includeDependencies = true;
_resolved = null;
@@ -420,6 +468,43 @@ public partial class TransportExport : ComponentBase
else set.Remove(id);
}
// ---- Step 1 site/instance helpers (M8 E1) ----
/// <summary>Instances loaded for a site, or an empty list when the site has none.</summary>
private IReadOnlyList<Instance> InstancesFor(int siteId) =>
_instancesBySiteId.TryGetValue(siteId, out var list) ? list : Array.Empty<Instance>();
/// <summary>Expand/collapse a site row's nested instance list (UI-only state).</summary>
private void ToggleSiteExpanded(int siteId)
{
if (!_expandedSites.Remove(siteId))
{
_expandedSites.Add(siteId);
}
}
/// <summary>
/// Toggle a whole site. Selecting a site lets the resolver pull its instances,
/// so on select we clear any redundant per-instance ticks for that site; on
/// deselect we leave individual instances untouched (the operator may still
/// want a subset). Matches the resolver's dedup semantics.
/// </summary>
private void ToggleSite(int siteId, bool value)
{
if (value)
{
_selectedSites.Add(siteId);
foreach (var instance in InstancesFor(siteId))
{
_selectedInstances.Remove(instance.Id);
}
}
else
{
_selectedSites.Remove(siteId);
}
}
// ---- Step 2 grouping helpers ----
/// <summary>
@@ -206,6 +206,8 @@
var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker);
<div>
@RenderMapSection();
<p class="text-body-secondary">
Review each artifact in the bundle and choose how it should be applied
to this environment. Identical items are skipped automatically; new
@@ -245,11 +247,22 @@
</tr>
@if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson))
{
var lineDiff = TryExtractLineDiff(item.FieldDiffJson);
<tr>
<td colspan="6">
<details>
<summary class="small">Field diff</summary>
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
@if (lineDiff is not null)
{
<div class="mt-2" data-testid="code-line-diff">
<div class="small text-body-secondary mb-1">Code changes</div>
<LineDiffView LineDiff="lineDiff" />
</div>
}
else
{
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
}
</details>
</td>
</tr>
@@ -283,6 +296,123 @@
</div>
};
// ============================================================
// Step 3 — Map sub-section (M8 E2)
// ============================================================
// Shown only when the preview references source-environment sites/connections
// the operator must resolve before import. For central-config-only bundles the
// preview carries no required mappings and this renders nothing.
private RenderFragment RenderMapSection() => __builder =>
{
if (_preview is null) return;
var hasSiteMappings = _preview.RequiredSiteMappings.Count > 0;
var hasConnMappings = _preview.RequiredConnectionMappings.Count > 0;
if (!hasSiteMappings && !hasConnMappings)
{
return;
}
<div class="card mb-4" data-testid="map-section">
<div class="card-header bg-body-tertiary">
<strong>Resolve site &amp; connection references</strong>
<span class="small text-body-secondary ms-2">
This bundle references sites/connections from its source environment.
Map each to an existing target, or create a new one.
</span>
</div>
<div class="card-body">
@if (hasSiteMappings)
{
<h6 class="small text-uppercase text-body-secondary">Sites</h6>
<table class="table table-sm align-middle mb-4" data-testid="map-sites-table">
<thead>
<tr>
<th>Source identifier</th>
<th>Source name</th>
<th>Map to target</th>
</tr>
</thead>
<tbody>
@foreach (var rsm in _preview.RequiredSiteMappings)
{
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c) ? c : CreateNewValue;
<tr data-testid="map-site-row">
<td><code>@rsm.SourceSiteIdentifier</code></td>
<td>@rsm.SourceSiteName</td>
<td>
<select class="form-select form-select-sm"
style="max-width: 22rem;"
data-testid="@($"map-site-select-{rsm.SourceSiteIdentifier}")"
value="@chosen"
@onchange="e => OnSiteChoiceChangedAsync(rsm.SourceSiteIdentifier, e.Value?.ToString())">
<option value="@CreateNewValue" selected="@(string.IsNullOrEmpty(chosen))">Create new</option>
@foreach (var site in _targetSites)
{
<option value="@site.SiteIdentifier" selected="@(chosen == site.SiteIdentifier)">
@site.Name (@site.SiteIdentifier)
</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
}
@if (hasConnMappings)
{
<h6 class="small text-uppercase text-body-secondary">Connections</h6>
@foreach (var grp in _preview.RequiredConnectionMappings.GroupBy(m => m.SourceSiteIdentifier))
{
var siteTarget = _siteChoices.TryGetValue(grp.Key, out var st) ? st : CreateNewValue;
var targetConns = ConnectionsForChosenTarget(grp.Key);
<div class="mb-3" data-testid="map-conn-group">
<div class="small text-body-secondary mb-1">
Site <code>@grp.Key</code>
@if (string.IsNullOrEmpty(siteTarget))
{
<span class="badge bg-success ms-1">new site</span>
}
</div>
<table class="table table-sm align-middle mb-0" data-testid="map-conns-table">
<thead>
<tr>
<th>Source connection</th>
<th>Map to target</th>
</tr>
</thead>
<tbody>
@foreach (var rcm in grp)
{
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
var chosenConn = _connectionChoices.TryGetValue(key, out var cc) ? cc : CreateNewValue;
<tr data-testid="map-conn-row">
<td>@rcm.SourceConnectionName</td>
<td>
<select class="form-select form-select-sm"
style="max-width: 22rem;"
data-testid="@($"map-conn-select-{rcm.SourceSiteIdentifier}-{rcm.SourceConnectionName}")"
value="@chosenConn"
@onchange="e => OnConnectionChoiceChanged(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, e.Value?.ToString())">
<option value="@CreateNewValue" selected="@(string.IsNullOrEmpty(chosenConn))">Create new</option>
@foreach (var conn in targetConns)
{
<option value="@conn.Name" selected="@(chosenConn == conn.Name)">@conn.Name</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
</div>
};
private RenderFragment RenderKindBadge(ImportPreviewItem item) => __builder =>
{
var (cls, label) = item.Kind switch
@@ -1,9 +1,12 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
@@ -68,6 +71,10 @@ public partial class TransportImport : ComponentBase, IDisposable
[Inject] private IAuditService AuditService { get; set; } = default!;
[Inject] private ScadaBridgeDbContext DbContext { get; set; } = default!;
// M8 E2: the Map step needs the destination environment's sites + each
// site's connections to populate the "map to existing target" dropdowns.
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
// ---- Wizard state ----
private ImportWizardStep _step = ImportWizardStep.Upload;
private string? _errorMessage;
@@ -98,6 +105,27 @@ public partial class TransportImport : ComponentBase, IDisposable
// Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup.
private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions;
// ---- Step 3 (Map sub-section, M8 E2): name mapping ----
// The sentinel dropdown value for "Create new" — empty string can't collide
// with a real SiteIdentifier / connection Name (both are non-empty).
private const string CreateNewValue = "";
// Destination environment's sites, loaded once when the preview carries any
// required mappings.
private IReadOnlyList<Site> _targetSites = Array.Empty<Site>();
// Per-target-site connections, loaded lazily and cached by SiteIdentifier.
private readonly Dictionary<string, IReadOnlyList<DataConnection>> _targetConnections = new();
// Operator's site choices, keyed by SourceSiteIdentifier. Value is the chosen
// target SiteIdentifier, or CreateNewValue ("") for "Create new". Seeded from
// each RequiredSiteMapping.AutoMatchTargetIdentifier.
private readonly Dictionary<string, string> _siteChoices = new();
// Operator's connection choices, keyed by (SourceSiteIdentifier, SourceConnectionName).
// Value is the chosen target connection Name, or CreateNewValue for "Create new".
private readonly Dictionary<(string SourceSite, string SourceConn), string> _connectionChoices = new();
// ---- Step 4: confirm ----
private string _confirmEnvironmentText = string.Empty;
@@ -377,6 +405,11 @@ public partial class TransportImport : ComponentBase, IDisposable
private void BackToUpload()
{
// Reset all wizard + Map state so a subsequent re-upload starts from a
// clean slate and cannot observe stale session / preview / name-map data
// from the previous flow. ResetSessionState also clears _preview,
// _resolutions, _siteChoices, _connectionChoices, etc.
ResetSessionState();
_step = ImportWizardStep.Upload;
_errorMessage = null;
}
@@ -392,6 +425,7 @@ public partial class TransportImport : ComponentBase, IDisposable
{
_preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None);
_resolutions = BuildDefaultResolutions(_preview);
await InitNameMappingStateAsync(_preview);
_step = ImportWizardStep.Diff;
}
catch (Exception ex)
@@ -400,6 +434,217 @@ public partial class TransportImport : ComponentBase, IDisposable
}
}
/// <summary>
/// M8 E2: prepares the Map sub-section state for a freshly loaded preview.
/// When the preview carries no required site/connection mappings this is a
/// no-op (the Map section is hidden) and we skip the site/connection reads
/// entirely — central-config-only bundles never touch the destination's
/// site catalogue.
///
/// <para>Default choices follow the reference precedence: each site/connection
/// is seeded to its <c>AutoMatch*</c> value when the importer found a match,
/// otherwise to "Create new". The operator's explicit dropdown choice then
/// wins over the auto-match default.</para>
/// </summary>
private async Task InitNameMappingStateAsync(ImportPreview preview)
{
_targetSites = Array.Empty<Site>();
_targetConnections.Clear();
_siteChoices.Clear();
_connectionChoices.Clear();
if (preview.RequiredSiteMappings.Count == 0 && preview.RequiredConnectionMappings.Count == 0)
{
return;
}
_targetSites = await SiteRepo.GetAllSitesAsync(CancellationToken.None);
foreach (var rsm in preview.RequiredSiteMappings)
{
// Seed to the auto-matched target identifier when present AND that
// target still exists in the destination; otherwise "Create new".
var seed = rsm.AutoMatchTargetIdentifier is not null
&& _targetSites.Any(s => s.SiteIdentifier == rsm.AutoMatchTargetIdentifier)
? rsm.AutoMatchTargetIdentifier
: CreateNewValue;
_siteChoices[rsm.SourceSiteIdentifier] = seed;
// Eagerly load the seeded target's connections so the connection-row
// dropdowns beneath this site can render their option sets without an
// async hop on first render.
if (!string.IsNullOrEmpty(seed) && !_targetConnections.ContainsKey(seed))
{
var target = _targetSites.First(s => s.SiteIdentifier == seed);
_targetConnections[seed] =
await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None);
}
}
foreach (var rcm in preview.RequiredConnectionMappings)
{
// Default the connection to its auto-match. We don't pre-validate it
// against the chosen target's connection list here — the dropdown's
// option set (driven by the chosen site) governs what's selectable,
// and the seed simply renders as the initial selection.
_connectionChoices[(rcm.SourceSiteIdentifier, rcm.SourceConnectionName)] =
rcm.AutoMatchTargetName ?? CreateNewValue;
}
}
/// <summary>
/// Returns the connections of the destination site the operator chose for
/// <paramref name="sourceSiteIdentifier"/> (used to populate a connection
/// row's dropdown). Reads the <c>_targetConnections</c> cache, which is
/// populated for every chosen target site by <see cref="InitNameMappingStateAsync"/>
/// (seeded auto-matches) and <see cref="OnSiteChoiceChangedAsync"/> (operator
/// changes). Returns an empty list when the source site maps to "Create new"
/// (no existing target connections to bind to).
/// </summary>
private IReadOnlyList<DataConnection> ConnectionsForChosenTarget(string sourceSiteIdentifier)
{
if (!_siteChoices.TryGetValue(sourceSiteIdentifier, out var targetSiteIdentifier)
|| string.IsNullOrEmpty(targetSiteIdentifier))
{
return Array.Empty<DataConnection>();
}
return _targetConnections.TryGetValue(targetSiteIdentifier, out var cached)
? cached
: Array.Empty<DataConnection>();
}
/// <summary>
/// Handles a site-mapping dropdown change. Records the choice, eagerly loads +
/// caches the new target's connections, then resets the per-connection choices
/// for every <see cref="RequiredConnectionMapping"/> under this source site so
/// that stale selections from the previous target cannot survive into
/// <see cref="BuildNameMap"/>. Each connection is re-seeded: if the new target
/// has a same-named connection the choice defaults to that name (matching the
/// auto-match logic in <see cref="InitNameMappingStateAsync"/>); otherwise it
/// falls back to the <see cref="CreateNewValue"/> sentinel ("Create new").
/// </summary>
private async Task OnSiteChoiceChangedAsync(string sourceSiteIdentifier, string? value)
{
var chosen = value ?? CreateNewValue;
_siteChoices[sourceSiteIdentifier] = chosen;
if (!string.IsNullOrEmpty(chosen) && !_targetConnections.ContainsKey(chosen))
{
var target = _targetSites.FirstOrDefault(s => s.SiteIdentifier == chosen);
if (target is not null)
{
_targetConnections[chosen] =
await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None);
}
}
// Reset connection choices for every RequiredConnectionMapping belonging
// to this source site. The previously-chosen connection name may not exist
// on the newly-chosen target site, and leaving it would cause BuildNameMap
// to emit a MapToExisting for a connection that does not exist there.
if (_preview is not null)
{
foreach (var rcm in _preview.RequiredConnectionMappings
.Where(m => m.SourceSiteIdentifier == sourceSiteIdentifier))
{
// Default to the new target's same-named connection when present;
// otherwise fall back to Create-new.
_connectionChoices[(sourceSiteIdentifier, rcm.SourceConnectionName)] =
(!string.IsNullOrEmpty(chosen)
&& _targetConnections.TryGetValue(chosen, out var conns)
&& conns.Any(c => c.Name == rcm.SourceConnectionName))
? rcm.SourceConnectionName
: CreateNewValue;
}
}
}
private void OnConnectionChoiceChanged(string sourceSiteIdentifier, string sourceConnectionName, string? value)
{
_connectionChoices[(sourceSiteIdentifier, sourceConnectionName)] = value ?? CreateNewValue;
}
/// <summary>
/// M8 E2: folds the operator's Map-step choices into a <see cref="BundleNameMap"/>.
/// A concrete chosen target → <see cref="MappingAction.MapToExisting"/> with that
/// target identifier/name; the "Create new" sentinel → <see cref="MappingAction.CreateNew"/>
/// with a null target. Iterates the preview's required mappings so the map's shape
/// always matches what the bundle references (a missing choice falls back to its
/// auto-match default, mirroring the seed). Returns <see cref="BundleNameMap.Empty"/>
/// when the preview carries no required mappings.
/// </summary>
private BundleNameMap BuildNameMap()
{
if (_preview is null
|| (_preview.RequiredSiteMappings.Count == 0 && _preview.RequiredConnectionMappings.Count == 0))
{
return BundleNameMap.Empty;
}
var sites = new List<SiteMapping>(_preview.RequiredSiteMappings.Count);
foreach (var rsm in _preview.RequiredSiteMappings)
{
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c)
? c
: (rsm.AutoMatchTargetIdentifier ?? CreateNewValue);
sites.Add(string.IsNullOrEmpty(chosen)
? new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.CreateNew, null)
: new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.MapToExisting, chosen));
}
var connections = new List<ConnectionMapping>(_preview.RequiredConnectionMappings.Count);
foreach (var rcm in _preview.RequiredConnectionMappings)
{
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
var chosen = _connectionChoices.TryGetValue(key, out var c)
? c
: (rcm.AutoMatchTargetName ?? CreateNewValue);
connections.Add(string.IsNullOrEmpty(chosen)
? new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.CreateNew, null)
: new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.MapToExisting, chosen));
}
return new BundleNameMap(sites, connections);
}
/// <summary>
/// M8 E2: parses a Modified item's <c>FieldDiffJson</c> and returns the
/// <c>lineDiff</c> object for the first code field that carries one, or null
/// when the diff has no line-level payload (ordinary fields render as a coarse
/// summary instead). Tolerant of malformed/absent JSON — a parse failure
/// simply yields null so the row degrades to the coarse field-diff view.
/// </summary>
internal static JsonElement? TryExtractLineDiff(string? fieldDiffJson)
{
if (string.IsNullOrWhiteSpace(fieldDiffJson))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(fieldDiffJson);
if (!doc.RootElement.TryGetProperty("changes", out var changes)
|| changes.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var change in changes.EnumerateArray())
{
if (change.ValueKind == JsonValueKind.Object
&& change.TryGetProperty("lineDiff", out var lineDiff)
&& lineDiff.ValueKind == JsonValueKind.Object)
{
// Clone so the element outlives the JsonDocument we dispose.
return lineDiff.Clone();
}
}
return null;
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Builds the default resolution per preview item:
/// <list type="bullet">
@@ -527,11 +772,16 @@ public partial class TransportImport : ComponentBase, IDisposable
try
{
var user = await Auth.GetCurrentUsernameAsync();
// M8 E2: fold the operator's Map-step choices into the name map. For
// central-config-only bundles (no required mappings) this is
// BundleNameMap.Empty, which the importer normalises away.
var nameMap = BuildNameMap();
_result = await BundleImporter.ApplyAsync(
_session.SessionId,
_resolutions.Values.ToList(),
user,
CancellationToken.None);
CancellationToken.None,
nameMap: nameMap);
_step = ImportWizardStep.Result;
}
catch (SemanticValidationException ex)
@@ -564,6 +814,11 @@ public partial class TransportImport : ComponentBase, IDisposable
_confirmEnvironmentText = string.Empty;
_result = null;
_validationErrors = null;
// M8 E2: clear the Map sub-section state too.
_targetSites = Array.Empty<Site>();
_targetConnections.Clear();
_siteChoices.Clear();
_connectionChoices.Clear();
}
/// <summary>
@@ -0,0 +1,133 @@
@using System.Text.Json
@*
LineDiffView (Component #24, Task M8 E2).
Renders a single Code field's "lineDiff" payload — the line-level diff that the
importer attaches to a Modified ImportPreviewItem's FieldDiffJson for code fields —
as a compact, GitHub-style +/- list. Pure presentation: it takes an already-parsed
JsonElement (the value of the "lineDiff" key) and walks its "hunks" array.
hunk op ∈ "context" | "add" | "remove" (lowercase):
context → muted line, both old/new line numbers
add → green-ish line, new line number only (no oldLineNo)
remove → red-ish line, old line number only (no newLineNo)
When the payload's "truncated" flag is true a trailing marker summarises the
addedCount / removedCount the diff could not show in full.
No third-party diff/charting library — Bootstrap utility classes + a small
monospace block only.
*@
@if (_hunks.Count == 0 && !_truncated)
{
<div class="text-muted small fst-italic" data-testid="line-diff-empty">No line-level changes.</div>
}
else
{
<div class="border rounded bg-body-tertiary font-monospace small overflow-auto"
style="max-height: 22rem;" data-testid="line-diff">
@foreach (var hunk in _hunks)
{
var (rowCls, gutter, sign) = hunk.Op switch
{
"add" => ("bg-success-subtle text-success-emphasis", FormatGutter(null, hunk.NewLineNo), "+"),
"remove" => ("bg-danger-subtle text-danger-emphasis", FormatGutter(hunk.OldLineNo, null), "-"),
_ => ("text-body-secondary", FormatGutter(hunk.OldLineNo, hunk.NewLineNo), " "),
};
<div class="d-flex @rowCls" data-testid="@($"line-diff-{hunk.Op}")">
<span class="px-2 text-body-tertiary text-nowrap user-select-none"
style="min-width: 6.5rem;">@gutter</span>
<span class="px-1 text-nowrap user-select-none">@sign</span>
<span class="px-2 text-break flex-grow-1" style="white-space: pre-wrap;">@hunk.Text</span>
</div>
}
</div>
@if (_truncated)
{
<div class="text-muted small fst-italic mt-1" data-testid="line-diff-truncated">
… diff truncated (+@_addedCount / -@_removedCount more)
</div>
}
}
@code {
/// <summary>
/// The parsed value of a Modified item's <c>FieldDiffJson</c> code field's
/// <c>lineDiff</c> key. When null the component renders nothing meaningful —
/// callers should only render it for code fields that actually carry a
/// <c>lineDiff</c> object.
/// </summary>
[Parameter]
public JsonElement? LineDiff { get; set; }
private readonly List<Hunk> _hunks = new();
private bool _truncated;
private int _addedCount;
private int _removedCount;
private sealed record Hunk(string Op, string Text, int? OldLineNo, int? NewLineNo);
protected override void OnParametersSet()
{
_hunks.Clear();
_truncated = false;
_addedCount = 0;
_removedCount = 0;
if (LineDiff is not { ValueKind: JsonValueKind.Object } payload)
{
return;
}
if (payload.TryGetProperty("truncated", out var truncatedEl)
&& truncatedEl.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
_truncated = truncatedEl.GetBoolean();
}
if (payload.TryGetProperty("addedCount", out var addedEl)
&& addedEl.ValueKind == JsonValueKind.Number)
{
_addedCount = addedEl.GetInt32();
}
if (payload.TryGetProperty("removedCount", out var removedEl)
&& removedEl.ValueKind == JsonValueKind.Number)
{
_removedCount = removedEl.GetInt32();
}
if (payload.TryGetProperty("hunks", out var hunksEl)
&& hunksEl.ValueKind == JsonValueKind.Array)
{
foreach (var h in hunksEl.EnumerateArray())
{
if (h.ValueKind != JsonValueKind.Object)
{
continue;
}
var op = h.TryGetProperty("op", out var opEl) && opEl.ValueKind == JsonValueKind.String
? opEl.GetString() ?? "context"
: "context";
var text = h.TryGetProperty("text", out var textEl) && textEl.ValueKind == JsonValueKind.String
? textEl.GetString() ?? string.Empty
: string.Empty;
int? oldLineNo = h.TryGetProperty("oldLineNo", out var oldEl) && oldEl.ValueKind == JsonValueKind.Number
? oldEl.GetInt32()
: null;
int? newLineNo = h.TryGetProperty("newLineNo", out var newEl) && newEl.ValueKind == JsonValueKind.Number
? newEl.GetInt32()
: null;
_hunks.Add(new Hunk(op, text, oldLineNo, newLineNo));
}
}
}
private static string FormatGutter(int? oldLineNo, int? newLineNo)
{
var left = oldLineNo?.ToString() ?? string.Empty;
var right = newLineNo?.ToString() ?? string.Empty;
return $"{left,3} {right,3}";
}
}
@@ -28,10 +28,22 @@ public interface IBundleImporter
/// <param name="resolutions">Per-artifact conflict resolutions from the preview step.</param>
/// <param name="user">Username of the operator performing the import, stamped in audit rows.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="nameMap">
/// The operator-supplied resolution of every source-environment site and connection
/// name the bundle references (M8 D1). Each entry is either
/// <see cref="MappingAction.MapToExisting"/> (bind to an existing target site/connection)
/// or <see cref="MappingAction.CreateNew"/> (create one from the bundle payload). A
/// site or connection that the bundle references but that has no explicit entry here is
/// auto-matched against the target by identity (existing → MapToExisting, otherwise
/// CreateNew). Trailing optional parameter — null is normalised to
/// <see cref="BundleNameMap.Empty"/> so callers that carry no site/instance payload
/// (e.g. central-config-only bundles) keep working unchanged.
/// </param>
/// <returns>A task that resolves to the result of the committed import transaction.</returns>
Task<ImportResult> ApplyAsync(
Guid sessionId,
IReadOnlyList<ImportResolution> resolutions,
string user,
CancellationToken ct = default);
CancellationToken ct = default,
BundleNameMap? nameMap = null);
}
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
/// <summary>
/// Computes the CURRENT flattened-config revision hash of a deployed instance —
/// the same value the deployment pipeline produces and stores in
/// <c>DeployedConfigSnapshot.RevisionHash</c>. Transport's bundle importer uses
/// this to enumerate stale instances after a template overwrite (#16): an
/// instance is stale when its freshly-computed hash no longer matches the hash
/// captured at deploy time.
/// <para>
/// The seam lives in Commons so the Transport component can depend on it without
/// referencing the DeploymentManager project (where the flattening pipeline that
/// implements it lives). The implementation re-flattens the instance via the
/// existing flattening pipeline over the SAME scoped <c>DbContext</c>, so it sees
/// any template rows the in-flight import has staged on the change tracker but
/// not yet committed.
/// </para>
/// </summary>
public interface IStaleInstanceProbe
{
/// <summary>
/// Computes the current flattened-config revision hash for the given instance,
/// or <c>null</c> when the hash cannot be computed (e.g. the instance or its
/// template chain cannot be resolved). Callers treat a <c>null</c> result as
/// "cannot determine staleness" and skip the instance — staleness is
/// informational, never a reason to abort an import.
/// </summary>
/// <param name="instanceId">The target instance's surrogate id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The current revision hash (e.g. <c>sha256:...</c>), or <c>null</c>.</returns>
Task<string?> GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default);
}
@@ -24,7 +24,13 @@ public sealed record ExportBundleCommand(
IReadOnlyList<string>? ApiMethodNames,
bool IncludeDependencies,
string? Passphrase,
string SourceEnvironment);
string SourceEnvironment,
// Additive (M8 B4): site/instance-scoped selection. Sites resolve by
// SiteIdentifier (preferred) or Name; instances resolve by UniqueName.
// Defaulted null so every existing positional caller keeps compiling; the
// handler normalizes null to "select nothing" (or everything under All=true).
IReadOnlyList<string>? SiteNames = null,
IReadOnlyList<string>? InstanceNames = null);
/// <summary>
/// Bundle body returned as base64-encoded ZIP. <see cref="ByteCount"/> is the
@@ -47,7 +53,43 @@ public sealed record PreviewBundleResult(
int AddCount,
int ModifiedCount,
int IdenticalCount,
int BlockerCount);
int BlockerCount,
// Additive (M8 D3): site/connection references the operator must resolve
// before import, carried verbatim from ImportPreview. Defaulted to empty so
// every existing positional caller keeps compiling.
IReadOnlyList<RequiredSiteMapping>? RequiredSiteMappings = null,
IReadOnlyList<RequiredConnectionMapping>? RequiredConnectionMappings = null)
{
/// <summary>Site references the operator must resolve before import (M8). Never null.</summary>
public IReadOnlyList<RequiredSiteMapping> RequiredSiteMappings { get; init; } =
RequiredSiteMappings ?? Array.Empty<RequiredSiteMapping>();
/// <summary>Connection references the operator must resolve before import (M8). Never null.</summary>
public IReadOnlyList<RequiredConnectionMapping> RequiredConnectionMappings { get; init; } =
RequiredConnectionMappings ?? Array.Empty<RequiredConnectionMapping>();
}
/// <summary>
/// One source-environment site → destination resolution carried on
/// <see cref="ImportBundleCommand"/> across the CLI ⇆ ManagementActor boundary.
/// A null/blank <paramref name="TargetSiteIdentifier"/> means CreateNew; a
/// present value means MapToExisting that target. Serializable via
/// System.Text.Json.
/// </summary>
public sealed record SiteMappingSpec(
string SourceSiteIdentifier,
string? TargetSiteIdentifier);
/// <summary>
/// One source-environment data-connection (scoped to a source site) →
/// destination resolution carried on <see cref="ImportBundleCommand"/>. A
/// null/blank <paramref name="TargetConnectionName"/> means CreateNew; a present
/// value means MapToExisting that target. Serializable via System.Text.Json.
/// </summary>
public sealed record ConnectionMappingSpec(
string SourceSiteIdentifier,
string SourceConnectionName,
string? TargetConnectionName);
/// <summary>
/// Loads, previews, and applies a bundle in a single call. The diff is built
@@ -60,8 +102,22 @@ public sealed record PreviewBundleResult(
/// Valid <see cref="DefaultConflictPolicy"/> values: <c>"skip"</c>,
/// <c>"overwrite"</c>, <c>"rename"</c>. Rename mints a unique suffix per row.
/// </para>
/// <para>
/// M8 (D3): <paramref name="SiteMappings"/> / <paramref name="ConnectionMappings"/>
/// supply explicit operator resolutions for the source-environment site/connection
/// references the bundle carries; the handler merges them with the preview's
/// auto-matches and the <paramref name="CreateMissingSites"/> /
/// <paramref name="CreateMissingConnections"/> flags into a
/// <see cref="BundleNameMap"/> passed to Apply. All four are defaulted so every
/// existing positional caller keeps compiling and central-config-only bundles
/// (no site/instance payload) carry an empty map.
/// </para>
/// </summary>
public sealed record ImportBundleCommand(
string Base64Bundle,
string? Passphrase,
string DefaultConflictPolicy);
string DefaultConflictPolicy,
IReadOnlyList<SiteMappingSpec>? SiteMappings = null,
IReadOnlyList<ConnectionMappingSpec>? ConnectionMappings = null,
bool CreateMissingSites = false,
bool CreateMissingConnections = false);
@@ -0,0 +1,53 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
/// <summary>
/// How a source-environment site or connection referenced by an incoming bundle is
/// resolved against the destination environment during import.
/// </summary>
public enum MappingAction
{
/// <summary>Bind the source name to an existing destination site/connection
/// (the corresponding <c>Target*</c> identifier is required).</summary>
MapToExisting,
/// <summary>Create a new destination site/connection from the bundle's payload
/// (the corresponding <c>Target*</c> identifier is left null).</summary>
CreateNew,
}
/// <summary>
/// Resolves one site referenced by an incoming bundle (M8 site/instance-scoped
/// transport). <paramref name="TargetSiteIdentifier"/> is required when
/// <paramref name="Action"/> is <see cref="MappingAction.MapToExisting"/> and null
/// when it is <see cref="MappingAction.CreateNew"/>.
/// </summary>
public sealed record SiteMapping(
string SourceSiteIdentifier,
MappingAction Action,
string? TargetSiteIdentifier);
/// <summary>
/// Resolves one data connection (scoped to a source site) referenced by an incoming
/// bundle. <paramref name="TargetConnectionName"/> is required when
/// <paramref name="Action"/> is <see cref="MappingAction.MapToExisting"/> and null
/// when it is <see cref="MappingAction.CreateNew"/>.
/// </summary>
public sealed record ConnectionMapping(
string SourceSiteIdentifier,
string SourceConnectionName,
MappingAction Action,
string? TargetConnectionName);
/// <summary>
/// The operator-supplied resolution of every source-environment site and connection
/// name an incoming bundle references, applied during import. Crosses the Central UI ⇆
/// ManagementActor boundary via System.Text.Json.
/// </summary>
public sealed record BundleNameMap(
IReadOnlyList<SiteMapping> Sites,
IReadOnlyList<ConnectionMapping> Connections)
{
/// <summary>An empty map (no site or connection mappings).</summary>
public static BundleNameMap Empty { get; } =
new(Array.Empty<SiteMapping>(), Array.Empty<ConnectionMapping>());
}
@@ -10,4 +10,9 @@ public sealed record BundleSummary(
int DbConnections,
int NotificationLists,
int SmtpConfigs,
int ApiMethods);
int ApiMethods,
// Additive (M8 A1): site/instance-scoped artifacts. Defaulted to 0 so manifests
// written by older exporters (which omit these JSON properties) deserialize fine.
int Sites = 0,
int DataConnections = 0,
int Instances = 0);
@@ -13,4 +13,15 @@ public sealed record ExportSelection(
IReadOnlyList<int> NotificationListIds,
IReadOnlyList<int> SmtpConfigurationIds,
IReadOnlyList<int> ApiMethodIds,
bool IncludeDependencies);
bool IncludeDependencies,
// Additive (M8 A1): site/instance-scoped export. Defaulted to empty so every
// existing positional caller keeps compiling; older callers select no sites/instances.
IReadOnlyList<int>? SiteIds = null,
IReadOnlyList<int>? InstanceIds = null)
{
/// <summary>Sites selected for site/instance-scoped export (M8). Never null.</summary>
public IReadOnlyList<int> SiteIds { get; init; } = SiteIds ?? Array.Empty<int>();
/// <summary>Instances selected for site/instance-scoped export (M8). Never null.</summary>
public IReadOnlyList<int> InstanceIds { get; init; } = InstanceIds ?? Array.Empty<int>();
}
@@ -11,6 +11,40 @@ public sealed record ImportPreviewItem(
string? FieldDiffJson,
string? BlockerReason);
/// <summary>
/// A site reference in an incoming bundle that the operator must resolve before import
/// (M8 A1). <paramref name="AutoMatchTargetIdentifier"/> is the destination site the
/// importer auto-matched by identifier, or null when no match was found.
/// </summary>
public sealed record RequiredSiteMapping(
string SourceSiteIdentifier,
string SourceSiteName,
string? AutoMatchTargetIdentifier);
/// <summary>
/// A connection reference (scoped to a source site) in an incoming bundle that the
/// operator must resolve before import (M8 A1). <paramref name="AutoMatchTargetName"/>
/// is the destination connection the importer auto-matched by name, or null when no
/// match was found.
/// </summary>
public sealed record RequiredConnectionMapping(
string SourceSiteIdentifier,
string SourceConnectionName,
string? AutoMatchTargetName);
public sealed record ImportPreview(
Guid SessionId,
IReadOnlyList<ImportPreviewItem> Items);
IReadOnlyList<ImportPreviewItem> Items,
// Additive (M8 A1): site/connection references the operator must resolve before
// applying. Defaulted to empty so every existing positional caller keeps compiling.
IReadOnlyList<RequiredSiteMapping>? RequiredSiteMappings = null,
IReadOnlyList<RequiredConnectionMapping>? RequiredConnectionMappings = null)
{
/// <summary>Site references the operator must resolve before import (M8). Never null.</summary>
public IReadOnlyList<RequiredSiteMapping> RequiredSiteMappings { get; init; } =
RequiredSiteMappings ?? Array.Empty<RequiredSiteMapping>();
/// <summary>Connection references the operator must resolve before import (M8). Never null.</summary>
public IReadOnlyList<RequiredConnectionMapping> RequiredConnectionMappings { get; init; } =
RequiredConnectionMappings ?? Array.Empty<RequiredConnectionMapping>();
}
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -40,6 +41,13 @@ public static class ServiceCollectionExtensions
services.AddScoped<IFlatteningPipeline, FlatteningPipeline>();
services.AddScoped<DeploymentService>();
services.AddScoped<ArtifactDeploymentService>();
// #16 (M8 D2): expose the flattening pipeline's revision-hash computation
// to the Transport bundle importer through the Commons IStaleInstanceProbe
// seam, so a template overwrite can enumerate the deployed instances whose
// flattened config has drifted from their deployed snapshot. Scoped so it
// shares the per-request DbContext with the in-flight import.
services.AddScoped<IStaleInstanceProbe, StaleInstanceProbe>();
return services;
}
@@ -0,0 +1,38 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// <summary>
/// <see cref="IStaleInstanceProbe"/> backed by the deployment flattening pipeline.
/// Re-flattens an instance through <see cref="IFlatteningPipeline"/> and returns
/// the computed revision hash — the exact same value
/// <c>DeploymentService.GetDeploymentComparisonAsync</c> compares against the
/// deployed snapshot to decide staleness. Hosted in DeploymentManager so the
/// Transport component (which references only Commons + TemplateEngine) can probe
/// staleness without taking a DeploymentManager project reference.
/// </summary>
public sealed class StaleInstanceProbe : IStaleInstanceProbe
{
private readonly IFlatteningPipeline _flatteningPipeline;
/// <summary>Initializes a new <see cref="StaleInstanceProbe"/>.</summary>
/// <param name="flatteningPipeline">The deployment flattening pipeline used to recompute the current revision hash.</param>
public StaleInstanceProbe(IFlatteningPipeline flatteningPipeline)
{
_flatteningPipeline = flatteningPipeline ?? throw new ArgumentNullException(nameof(flatteningPipeline));
}
/// <inheritdoc />
public async Task<string?> GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default)
{
// The pipeline returns a Result; a flatten failure (e.g. unresolvable
// template chain mid-import) yields null so the caller treats the
// instance as "staleness indeterminate" and skips it. Flattening reuses
// the scoped ITemplateEngineRepository, so it observes template rows the
// in-flight import has staged on the shared change tracker.
var result = await _flatteningPipeline
.FlattenAndValidateAsync(instanceId, cancellationToken)
.ConfigureAwait(false);
return result.IsSuccess ? result.Value.RevisionHash : null;
}
}
@@ -2305,6 +2305,7 @@ public class ManagementActor : ReceiveActor
var externalRepo = sp.GetRequiredService<IExternalSystemRepository>();
var notifRepo = sp.GetRequiredService<INotificationRepository>();
var inboundRepo = sp.GetRequiredService<IInboundApiRepository>();
var siteRepo = sp.GetRequiredService<ISiteRepository>();
var templates = await templateRepo.GetAllTemplatesAsync();
var sharedScripts = await templateRepo.GetAllSharedScriptsAsync();
@@ -2314,6 +2315,10 @@ public class ManagementActor : ReceiveActor
var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync();
// Inbound API keys are not transported between environments (re-arch C4); only methods.
var apiMethods = await inboundRepo.GetAllApiMethodsAsync();
// M8 (B4): site/instance-scoped selection. Sites match on SiteIdentifier
// (preferred) or Name; instances match on UniqueName.
var sites = await siteRepo.GetAllSitesAsync();
var instances = await templateRepo.GetAllInstancesAsync();
int[] ResolveIds<T>(IReadOnlyList<T> all, IReadOnlyList<string>? names,
Func<T, string> getName, Func<T, int> getId, string entityType)
@@ -2333,6 +2338,33 @@ public class ManagementActor : ReceiveActor
return matched;
}
// Sites accept EITHER the SiteIdentifier (preferred) or the friendly Name
// per requested token, so this needs a two-key matcher rather than the
// single-key ResolveIds above. Under All=true every site is included.
int[] ResolveSiteIds()
{
if (cmd.All) return sites.Select(s => s.Id).ToArray();
var names = cmd.SiteNames;
if (names is null || names.Count == 0) return Array.Empty<int>();
var matchedIds = new List<int>();
var unmatched = new List<string>();
foreach (var token in names)
{
var site = sites.FirstOrDefault(s =>
string.Equals(s.SiteIdentifier, token, StringComparison.Ordinal))
?? sites.FirstOrDefault(s =>
string.Equals(s.Name, token, StringComparison.Ordinal));
if (site is null) unmatched.Add(token);
else matchedIds.Add(site.Id);
}
if (unmatched.Count > 0)
{
throw new ManagementCommandException(
$"Unknown site identifier/name(s): {string.Join(", ", unmatched.OrderBy(n => n, StringComparer.Ordinal))}.");
}
return matchedIds.Distinct().ToArray();
}
var selection = new ExportSelection(
TemplateIds: ResolveIds(templates, cmd.TemplateNames, t => t.Name, t => t.Id, "template"),
SharedScriptIds: ResolveIds(sharedScripts, cmd.SharedScriptNames, s => s.Name, s => s.Id, "shared script"),
@@ -2343,7 +2375,11 @@ public class ManagementActor : ReceiveActor
// preview row shows the Host value, so the CLI uses Host too.
SmtpConfigurationIds: ResolveIds(smtpConfigs, cmd.SmtpConfigurationNames, s => s.Host, s => s.Id, "SMTP configuration"),
ApiMethodIds: ResolveIds(apiMethods, cmd.ApiMethodNames, m => m.Name, m => m.Id, "API method"),
IncludeDependencies: cmd.IncludeDependencies);
IncludeDependencies: cmd.IncludeDependencies,
// M8 (B4): site/instance-scoped selection. Under All=true include every
// site + instance, mirroring how All includes every template/etc.
SiteIds: ResolveSiteIds(),
InstanceIds: ResolveIds(instances, cmd.InstanceNames, i => i.UniqueName, i => i.Id, "instance"));
var exporter = sp.GetRequiredService<IBundleExporter>();
await using var stream = await exporter.ExportAsync(
@@ -2385,7 +2421,11 @@ public class ManagementActor : ReceiveActor
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
return new PreviewBundleResult(preview.Items, adds, mods, ids, blocks);
// M8 (D3): surface the required site/connection mappings so an operator
// (CLI or UI) sees which references need resolving before applying.
return new PreviewBundleResult(
preview.Items, adds, mods, ids, blocks,
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
}
/// <summary>
@@ -2426,10 +2466,19 @@ public class ManagementActor : ReceiveActor
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
}
// M8 (D3): resolve every required site/connection reference into a
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
// fall back to the preview's auto-match; otherwise (no match) create-new
// ONLY if the create-missing flag is set, else fail with a clear message.
var nameMap = BuildNameMap(cmd, preview);
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
// entity (e.g. one row per modified member of a template), but ApplyAsync
// requires a unique resolution per key. Last-write-wins matches the
// Central UI's TransportImport.BuildDefaultResolutions behavior.
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
// DataConnection rows the preview item Name is already site-qualified
// ({site}/{name}, D1-FIX), so keying generically by (EntityType, Name) is
// automatically correct — no bare-connection-name special case needed.
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
@@ -2448,7 +2497,96 @@ public class ManagementActor : ReceiveActor
item.EntityType, item.Name, action, renameTo);
}
return await importer.ApplyAsync(session.SessionId, resolutionsMap.Values.ToList(), username);
return await importer.ApplyAsync(
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
}
/// <summary>
/// Merges the operator-supplied <see cref="SiteMappingSpec"/> /
/// <see cref="ConnectionMappingSpec"/> lists with the preview's auto-matches
/// and the create-missing flags into a <see cref="BundleNameMap"/>.
/// <para>
/// Per required site reference: an explicit spec wins (target present →
/// <see cref="MappingAction.MapToExisting"/>; null/blank target →
/// <see cref="MappingAction.CreateNew"/>); otherwise the preview's
/// <see cref="RequiredSiteMapping.AutoMatchTargetIdentifier"/> is used when
/// present (MapToExisting); otherwise CreateNew only when
/// <see cref="ImportBundleCommand.CreateMissingSites"/> is set, else the
/// reference is unresolved and the import fails. Connections mirror this with
/// <see cref="ImportBundleCommand.CreateMissingConnections"/>.
/// </para>
/// </summary>
private static BundleNameMap BuildNameMap(ImportBundleCommand cmd, ImportPreview preview)
{
var siteSpecs = (cmd.SiteMappings ?? Array.Empty<SiteMappingSpec>())
.ToDictionary(s => s.SourceSiteIdentifier, StringComparer.Ordinal);
var connSpecs = (cmd.ConnectionMappings ?? Array.Empty<ConnectionMappingSpec>())
.ToDictionary(
c => (c.SourceSiteIdentifier, c.SourceConnectionName),
c => c);
var siteMappings = new List<SiteMapping>();
var unresolved = new List<string>();
foreach (var required in preview.RequiredSiteMappings)
{
if (siteSpecs.TryGetValue(required.SourceSiteIdentifier, out var spec))
{
siteMappings.Add(string.IsNullOrWhiteSpace(spec.TargetSiteIdentifier)
? new SiteMapping(required.SourceSiteIdentifier, MappingAction.CreateNew, null)
: new SiteMapping(required.SourceSiteIdentifier, MappingAction.MapToExisting, spec.TargetSiteIdentifier));
}
else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetIdentifier))
{
siteMappings.Add(new SiteMapping(
required.SourceSiteIdentifier, MappingAction.MapToExisting, required.AutoMatchTargetIdentifier));
}
else if (cmd.CreateMissingSites)
{
siteMappings.Add(new SiteMapping(
required.SourceSiteIdentifier, MappingAction.CreateNew, null));
}
else
{
unresolved.Add($"site '{required.SourceSiteIdentifier}'");
}
}
var connMappings = new List<ConnectionMapping>();
foreach (var required in preview.RequiredConnectionMappings)
{
var key = (required.SourceSiteIdentifier, required.SourceConnectionName);
if (connSpecs.TryGetValue(key, out var spec))
{
connMappings.Add(string.IsNullOrWhiteSpace(spec.TargetConnectionName)
? new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null)
: new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, spec.TargetConnectionName));
}
else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetName))
{
connMappings.Add(new ConnectionMapping(
required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, required.AutoMatchTargetName));
}
else if (cmd.CreateMissingConnections)
{
connMappings.Add(new ConnectionMapping(
required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null));
}
else
{
unresolved.Add($"connection '{required.SourceSiteIdentifier}/{required.SourceConnectionName}'");
}
}
if (unresolved.Count > 0)
{
throw new ManagementCommandException(
$"Import has {unresolved.Count} unresolved mapping(s): " +
$"{string.Join(", ", unresolved.OrderBy(u => u, StringComparer.Ordinal))}. " +
"Supply --map-site/--map-connection for each, or pass " +
"--create-missing-sites/--create-missing-connections to create them from the bundle.");
}
return new BundleNameMap(siteMappings, connMappings);
}
private static byte[] DecodeBundle(string base64)
@@ -73,6 +73,10 @@ public sealed class BundleExporter : IBundleExporter
var resolved = await _resolver.ResolveAsync(selection, cancellationToken).ConfigureAwait(false);
// 2. Convert to the wire-shaped DTO (strips EF identity, refs-by-name).
// M8 (B4): the resolver's site/dataConnection/instance closure is wired
// through via object-initializer — without this the aggregate would
// carry the empty defaults and the bundle would ship empty site/
// instance arrays even when those were selected (review item I3).
var aggregate = new EntityAggregate(
TemplateFolders: resolved.TemplateFolders,
Templates: resolved.Templates,
@@ -83,7 +87,12 @@ public sealed class BundleExporter : IBundleExporter
NotificationLists: resolved.NotificationLists,
SmtpConfigurations: resolved.SmtpConfigs,
// Inbound API keys are not transported between environments (re-arch C4).
ApiMethods: resolved.ApiMethods);
ApiMethods: resolved.ApiMethods)
{
Sites = resolved.Sites,
DataConnections = resolved.DataConnections,
Instances = resolved.Instances,
};
var contentDto = _entitySerializer.ToBundleContent(aggregate);
// 3. Per-category summary counts — used for fast humanly-readable preview at import.
@@ -95,7 +104,15 @@ public sealed class BundleExporter : IBundleExporter
DbConnections: resolved.DatabaseConnections.Count,
NotificationLists: resolved.NotificationLists.Count,
SmtpConfigs: resolved.SmtpConfigs.Count,
ApiMethods: resolved.ApiMethods.Count);
ApiMethods: resolved.ApiMethods.Count,
// M8 (B3): additive site/dataConnection/instance counts. Sourced from
// the serialized content DTO — the same arrays the importer reads —
// so the manifest summary always matches the packed payload. These
// arrays default to empty until B1/B2 populate them, so the counts
// are 0 today and become correct once those waves land.
Sites: contentDto.Sites.Count,
DataConnections: contentDto.DataConnections.Count,
Instances: contentDto.Instances.Count);
// 4. Build a TEMPLATE manifest. BundleSerializer.Pack re-stamps both
// ContentHash and EncryptionMetadata against the bytes it actually
@@ -1,7 +1,9 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
@@ -22,6 +24,13 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Export;
/// </list>
/// Templates are returned topologically sorted (base-before-derived) via Kahn's
/// algorithm so importers can apply them in order.
/// <para>
/// M8 adds site/instance-scoped expansion: selecting a site pulls its
/// <c>DataConnection</c>s and <c>Instance</c>s; selecting an instance (with
/// <see cref="ExportSelection.IncludeDependencies"/>) pulls its owning site, the
/// data connections it binds, and feeds its template into the existing template
/// closure so the template/shared-script/external-system graph expands too.
/// </para>
/// </summary>
public sealed class DependencyResolver
{
@@ -29,22 +38,26 @@ public sealed class DependencyResolver
private readonly IExternalSystemRepository _externalSystems;
private readonly INotificationRepository _notifications;
private readonly IInboundApiRepository _inboundApi;
private readonly ISiteRepository _siteRepository;
/// <summary>Initializes a new instance of <see cref="DependencyResolver"/>.</summary>
/// <param name="templates">Repository for template and shared script access.</param>
/// <param name="templates">Repository for template, instance and shared script access.</param>
/// <param name="externalSystems">Repository for external system definitions and methods.</param>
/// <param name="notifications">Repository for notification lists and SMTP configurations.</param>
/// <param name="inboundApi">Repository for inbound API keys and methods.</param>
/// <param name="siteRepository">Repository for sites and site-scoped data connections.</param>
public DependencyResolver(
ITemplateEngineRepository templates,
IExternalSystemRepository externalSystems,
INotificationRepository notifications,
IInboundApiRepository inboundApi)
IInboundApiRepository inboundApi,
ISiteRepository siteRepository)
{
_templates = templates ?? throw new ArgumentNullException(nameof(templates));
_externalSystems = externalSystems ?? throw new ArgumentNullException(nameof(externalSystems));
_notifications = notifications ?? throw new ArgumentNullException(nameof(notifications));
_inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
_siteRepository = siteRepository ?? throw new ArgumentNullException(nameof(siteRepository));
}
/// <summary>Expands the selection into a fully self-consistent set of entities for bundling.</summary>
@@ -108,9 +121,53 @@ public sealed class DependencyResolver
if (m is not null) apiMethods[m.Id] = m;
}
// ---- Seed: site/instance-scoped selection (M8) ----
// sites/dataConnections/instances are keyed by surrogate id for dedup.
var sites = new Dictionary<int, Site>();
var dataConnections = new Dictionary<int, DataConnection>();
var instances = new Dictionary<int, Instance>();
// Selecting a SITE pulls its data connections and all of its instances. The
// instance's overrides/bindings are loaded eagerly so the serializer can read
// them off the entity navigation collections.
foreach (var siteId in selection.SiteIds.Distinct())
{
var site = await _siteRepository.GetSiteByIdAsync(siteId, ct).ConfigureAwait(false);
if (site is null) continue;
sites[site.Id] = site;
foreach (var conn in await _siteRepository.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false))
{
dataConnections[conn.Id] = conn;
}
foreach (var siteInstance in await _siteRepository.GetInstancesBySiteIdAsync(site.Id, ct).ConfigureAwait(false))
{
if (instances.ContainsKey(siteInstance.Id)) continue;
var loaded = await LoadInstanceWithChildrenAsync(siteInstance.Id, ct).ConfigureAwait(false);
if (loaded is not null) instances[loaded.Id] = loaded;
}
}
// Selecting an INSTANCE directly. Dedup against instances already pulled in
// via their owning site above (a site + one of its instances selected together
// must not double-add the instance).
foreach (var instanceId in selection.InstanceIds.Distinct())
{
if (instances.ContainsKey(instanceId)) continue;
var loaded = await LoadInstanceWithChildrenAsync(instanceId, ct).ConfigureAwait(false);
if (loaded is not null) instances[loaded.Id] = loaded;
}
// ---- Dependency expansion ----
if (selection.IncludeDependencies)
{
// Each gathered instance pulls in its owning site, the data connections it
// binds, and (by feeding instance.TemplateId into the template dictionary)
// its template — so the template/shared-script/external-system closure below
// expands transitively over instance templates too.
await ExpandSiteInstanceClosureAsync(instances, sites, dataConnections, templates, ct).ConfigureAwait(false);
await ExpandTemplateClosureAsync(templates, ct).ConfigureAwait(false);
await ExpandSharedScriptsFromTemplatesAsync(templates, sharedScripts, ct).ConfigureAwait(false);
await ExpandSharedScriptsFromApiMethodsAsync(apiMethods, sharedScripts, ct).ConfigureAwait(false);
@@ -134,6 +191,39 @@ public sealed class DependencyResolver
// ---- Topological sort of templates (base-before-derived) ----
var orderedTemplates = TopologicallySortTemplates(templates.Values);
// ---- Deterministic site/instance ordering (M8) ----
// siteId → SiteIdentifier map: every connection's/instance's owning site lands
// in `sites` after closure expansion, so ordering + dependsOn edges resolve names.
var siteIdentifierById = sites.Values.ToDictionary(s => s.Id, s => s.SiteIdentifier);
// I1: when an instance/data-connection is selected directly with
// IncludeDependencies=false, its owning site is not packed (so it never
// lands in `sites`) and the manifest dep-edge would degrade to the raw id
// (Site:<rawId>) via SiteIdentifierOf's fallback. Resolve the identifier
// for any referenced-but-unpacked owning site so the manifest reads
// Site:<identifier> regardless of the deps flag — readability only, the
// site row itself stays out of the bundle.
var referencedSiteIds = instances.Values.Select(i => i.SiteId)
.Concat(dataConnections.Values.Select(c => c.SiteId))
.Distinct()
.Where(id => !siteIdentifierById.ContainsKey(id));
foreach (var siteId in referencedSiteIds)
{
var site = await _siteRepository.GetSiteByIdAsync(siteId, ct).ConfigureAwait(false);
if (site is not null) siteIdentifierById[site.Id] = site.SiteIdentifier;
}
var orderedSites = sites.Values
.OrderBy(s => s.SiteIdentifier, StringComparer.Ordinal)
.ToList();
var orderedDataConnections = dataConnections.Values
.OrderBy(c => SiteIdentifierOf(c.SiteId, siteIdentifierById), StringComparer.Ordinal)
.ThenBy(c => c.Name, StringComparer.Ordinal)
.ToList();
var orderedInstances = instances.Values
.OrderBy(i => i.UniqueName, StringComparer.Ordinal)
.ToList();
// ---- Build deterministic content manifest ----
var manifest = BuildContentManifest(
folders,
@@ -144,7 +234,12 @@ public sealed class DependencyResolver
dbConnections.Values,
notificationLists.Values,
smtpConfigs.Values,
apiMethods.Values);
apiMethods.Values,
orderedSites,
orderedDataConnections,
orderedInstances,
siteIdentifierById,
templates);
return new ResolvedExport(
TemplateFolders: folders,
@@ -156,9 +251,101 @@ public sealed class DependencyResolver
NotificationLists: notificationLists.Values.OrderBy(n => n.Name, StringComparer.Ordinal).ToList(),
SmtpConfigs: smtpConfigs.Values.OrderBy(s => s.Host, StringComparer.Ordinal).ToList(),
ApiMethods: apiMethods.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(),
ContentManifest: manifest);
ContentManifest: manifest)
{
Sites = orderedSites,
DataConnections = orderedDataConnections,
Instances = orderedInstances,
};
}
// ---- Instance child loading (M8) ----
// Loads an instance and attaches its overrides/bindings onto the entity's own
// navigation collections, so the serializer reads children off the entity (the
// same shape central-config entities use). Returns null if the instance is gone.
private async Task<Instance?> LoadInstanceWithChildrenAsync(int instanceId, CancellationToken ct)
{
var instance = await _templates.GetInstanceByIdAsync(instanceId, ct).ConfigureAwait(false);
if (instance is null) return null;
var attributeOverrides = await _templates.GetOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
var alarmOverrides = await _templates.GetAlarmOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
var nativeAlarmOverrides = await _templates.GetNativeAlarmSourceOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
var bindings = await _templates.GetBindingsByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
instance.AttributeOverrides = attributeOverrides.ToList();
instance.AlarmOverrides = alarmOverrides.ToList();
instance.NativeAlarmSourceOverrides = nativeAlarmOverrides.ToList();
instance.ConnectionBindings = bindings.ToList();
return instance;
}
// ---- Site/instance closure (M8) ----
// For every gathered instance: ensure its owning Site is in the bundle, include
// the DataConnections it binds (via ConnectionBinding.DataConnectionId resolved
// within the instance's site, plus NativeAlarmSourceOverride.ConnectionNameOverride
// resolved by name within that site), and feed its TemplateId into the template
// dictionary so the template closure expands over it.
private async Task ExpandSiteInstanceClosureAsync(
Dictionary<int, Instance> instances,
Dictionary<int, Site> sites,
Dictionary<int, DataConnection> dataConnections,
Dictionary<int, Template> templates,
CancellationToken ct)
{
// Snapshot the instances: the loop only grows sites/dataConnections/templates,
// never instances, so iterating a snapshot keeps the dictionary stable.
foreach (var instance in instances.Values.ToList())
{
// 1. Owning site.
if (!sites.ContainsKey(instance.SiteId))
{
var site = await _siteRepository.GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false);
if (site is not null) sites[site.Id] = site;
}
// 2. Template → the existing template closure picks it up below.
if (!templates.ContainsKey(instance.TemplateId))
{
var template = await _templates.GetTemplateWithChildrenAsync(instance.TemplateId, ct).ConfigureAwait(false);
if (template is not null) templates[template.Id] = template;
}
// 3. Data connections the instance references, resolved within its site.
// Connection bindings reference connections by id; native-alarm-source
// overrides reference them by name — resolve both against the site set.
var bindingConnectionIds = instance.ConnectionBindings
.Select(b => b.DataConnectionId)
.Where(id => !dataConnections.ContainsKey(id))
.Distinct()
.ToList();
var referencedConnectionNames = instance.NativeAlarmSourceOverrides
.Select(o => o.ConnectionNameOverride)
.Where(n => !string.IsNullOrEmpty(n))
.Select(n => n!)
.ToHashSet(StringComparer.Ordinal);
if (bindingConnectionIds.Count == 0 && referencedConnectionNames.Count == 0) continue;
var siteConnections = await _siteRepository
.GetDataConnectionsBySiteIdAsync(instance.SiteId, ct)
.ConfigureAwait(false);
foreach (var conn in siteConnections)
{
if (dataConnections.ContainsKey(conn.Id)) continue;
if (bindingConnectionIds.Contains(conn.Id) || referencedConnectionNames.Contains(conn.Name))
{
dataConnections[conn.Id] = conn;
}
}
}
}
private static string SiteIdentifierOf(int siteId, IReadOnlyDictionary<int, string> siteIdentifierById) =>
siteIdentifierById.TryGetValue(siteId, out var id) ? id : siteId.ToString();
// ---- Template composition closure ----
private async Task ExpandTemplateClosureAsync(Dictionary<int, Template> templates, CancellationToken ct)
{
@@ -362,7 +549,12 @@ public sealed class DependencyResolver
IEnumerable<DatabaseConnectionDefinition> dbConnections,
IEnumerable<NotificationList> notificationLists,
IEnumerable<SmtpConfiguration> smtpConfigs,
IEnumerable<ApiMethod> apiMethods)
IEnumerable<ApiMethod> apiMethods,
IReadOnlyList<Site> sites,
IReadOnlyList<DataConnection> dataConnections,
IReadOnlyList<Instance> instances,
IReadOnlyDictionary<int, string> siteIdentifierById,
IReadOnlyDictionary<int, Template> templatesById)
{
var entries = new List<ManifestContentEntry>();
@@ -418,6 +610,66 @@ public sealed class DependencyResolver
entries.Add(new ManifestContentEntry("ApiMethod", m.Name, 1, Array.Empty<string>()));
}
// ---- M8: site/instance-scoped manifest rows ----
// Sites are roots (no dependsOn). DataConnections depend on their site.
// Instances depend on their template, their site, and each data connection
// they bind. Inputs arrive pre-ordered, so iterate as-is for determinism.
var connectionById = dataConnections.ToDictionary(c => c.Id);
var connectionBySiteAndName = dataConnections
.GroupBy(c => c.SiteId)
.ToDictionary(g => g.Key, g => g.ToDictionary(c => c.Name, StringComparer.Ordinal));
foreach (var site in sites)
{
entries.Add(new ManifestContentEntry("Site", site.SiteIdentifier, 1, Array.Empty<string>()));
}
foreach (var conn in dataConnections)
{
var siteIdentifier = SiteIdentifierOf(conn.SiteId, siteIdentifierById);
entries.Add(new ManifestContentEntry(
"DataConnection",
$"{siteIdentifier}/{conn.Name}",
1,
new[] { $"Site:{siteIdentifier}" }));
}
foreach (var instance in instances)
{
var siteIdentifier = SiteIdentifierOf(instance.SiteId, siteIdentifierById);
var templateName = templatesById.TryGetValue(instance.TemplateId, out var tpl)
? tpl.Name
: instance.TemplateId.ToString();
var deps = new List<string>
{
$"Template:{templateName}",
$"Site:{siteIdentifier}",
};
// Connection-binding edges (resolved by id within the export closure).
foreach (var binding in instance.ConnectionBindings)
{
if (connectionById.TryGetValue(binding.DataConnectionId, out var conn))
{
deps.Add($"DataConnection:{SiteIdentifierOf(conn.SiteId, siteIdentifierById)}/{conn.Name}");
}
}
// Native-alarm-source connection-name overrides (resolved by name within the site).
if (connectionBySiteAndName.TryGetValue(instance.SiteId, out var byName))
{
foreach (var ovr in instance.NativeAlarmSourceOverrides)
{
if (!string.IsNullOrEmpty(ovr.ConnectionNameOverride)
&& byName.ContainsKey(ovr.ConnectionNameOverride))
{
deps.Add($"DataConnection:{siteIdentifier}/{ovr.ConnectionNameOverride}");
}
}
}
var orderedDeps = deps.Distinct().OrderBy(x => x, StringComparer.Ordinal).ToList();
entries.Add(new ManifestContentEntry("Instance", instance.UniqueName, 1, orderedDeps));
}
return entries;
}
}
@@ -1,7 +1,9 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; // ApiMethod
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
@@ -25,4 +27,33 @@ public sealed record ResolvedExport(
IReadOnlyList<SmtpConfiguration> SmtpConfigs,
// Inbound API keys are not transported between environments (re-arch C4); only methods.
IReadOnlyList<ApiMethod> ApiMethods,
IReadOnlyList<ManifestContentEntry> ContentManifest);
IReadOnlyList<ManifestContentEntry> ContentManifest)
{
// M8: site/instance-scoped closure. Additive init-only collections with empty
// defaults so the positional ctor stays source-compatible for callers/tests
// that only resolve central-config selections (sites/instances stay empty).
// The resolver opts in via object-initializer. Each <see cref="Instance"/>
// carries its overrides/bindings on its own navigation collections
// (AttributeOverrides / AlarmOverrides / NativeAlarmSourceOverrides /
// ConnectionBindings), populated by the resolver — the serializer reads them
// off the entity, matching how the central-config entities carry their children.
//
// Ordering is fixed by the resolver: Sites by SiteIdentifier; DataConnections
// by (SiteIdentifier, Name); Instances by UniqueName.
/// <summary>Sites in the export closure, ordered by <see cref="Site.SiteIdentifier"/>.</summary>
public IReadOnlyList<Site> Sites { get; init; } = Array.Empty<Site>();
/// <summary>
/// Site-scoped <see cref="DataConnection"/>s in the closure, ordered by
/// (owning site identifier, connection name).
/// </summary>
public IReadOnlyList<DataConnection> DataConnections { get; init; } = Array.Empty<DataConnection>();
/// <summary>
/// Instances in the closure, ordered by <see cref="Instance.UniqueName"/>. Each
/// carries its loaded override/binding child collections on its navigation
/// properties.
/// </summary>
public IReadOnlyList<Instance> Instances { get; init; } = Array.Empty<Instance>();
}
@@ -2,8 +2,10 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
@@ -17,11 +19,15 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// diff in JSON.
/// <para>
/// "Coarse" means: each persistent field is compared as a value; differing
/// fields appear in <c>changes</c> with old/new values (or hashes for large
/// blobs like script code). Per-line / Myers-style diff is explicitly out of
/// scope for v1 — the design plan defers it to a follow-up task. Script
/// bodies record only a line-count delta to give the operator a sense of the
/// change without paying the diff cost up front.
/// non-code fields appear in <c>changes</c> with old/new values. Code / large-
/// text fields (script bodies, API-method scripts) instead carry a structured
/// per-line Myers diff (T20): the <see cref="FieldChange.OldValue"/> /
/// <see cref="FieldChange.NewValue"/> keep a compact <c>&lt;N lines&gt;</c>
/// summary for fallback rendering, and a <see cref="FieldChange.LineDiff"/>
/// payload carries the hunk lines (+/-/context) plus add/remove totals and a
/// truncation flag. The diff is size-capped via <see cref="LineDiffer"/>'s
/// <c>maxLines</c> cap so <see cref="ImportPreviewItem.FieldDiffJson"/> stays
/// bounded.
/// </para>
/// <para>
/// Entity versions are not yet tracked on the POCOs, so the
@@ -287,6 +293,153 @@ public sealed class ArtifactDiff
return BuildItem("TemplateFolder", incoming.Name, changes);
}
// ---- Site / Connection / Instance (M8) ----
/// <summary>
/// Compares an incoming site against the existing site in the database.
/// Identity is the <c>SiteIdentifier</c>; the diff is coarse over the
/// display name, description, and the four cluster/gRPC node addresses.
/// </summary>
/// <param name="incoming">The incoming site from the bundle.</param>
/// <param name="existing">The existing site in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareSite(SiteDto incoming, Site? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("Site", incoming.SiteIdentifier);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Name", existing.Name, incoming.Name);
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
AddIfDifferent(changes, "NodeAAddress", existing.NodeAAddress, incoming.NodeAAddress);
AddIfDifferent(changes, "NodeBAddress", existing.NodeBAddress, incoming.NodeBAddress);
AddIfDifferent(changes, "GrpcNodeAAddress", existing.GrpcNodeAAddress, incoming.GrpcNodeAAddress);
AddIfDifferent(changes, "GrpcNodeBAddress", existing.GrpcNodeBAddress, incoming.GrpcNodeBAddress);
return BuildItem("Site", incoming.SiteIdentifier, changes);
}
/// <summary>
/// Compares an incoming site-scoped data connection (the <c>Sites.DataConnection</c>
/// entity, NOT the External-System database connection) against the existing one.
/// The Primary/Backup protocol configuration lives in the DTO's
/// <see cref="SecretsBlock"/>, so it is compared <b>presence-only</b> — the diff
/// never echoes the configuration value, mirroring the external-system / DB-connection
/// secret handling.
/// </summary>
/// <param name="incoming">The incoming data connection from the bundle.</param>
/// <param name="existing">The existing data connection in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareDataConnection(DataConnectionDto incoming, DataConnection? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("DataConnection", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Protocol", existing.Protocol, incoming.Protocol);
AddIfDifferent(changes, "FailoverRetryCount", existing.FailoverRetryCount, incoming.FailoverRetryCount);
// Protocol config rides in Secrets (PrimaryConfiguration / BackupConfiguration).
// Presence-only comparison — never echo the config value.
AddSecretPresenceChange(changes, "Secrets.PrimaryConfiguration",
!string.IsNullOrEmpty(existing.PrimaryConfiguration),
HasSecretKey(incoming.Secrets, "PrimaryConfiguration"));
AddSecretPresenceChange(changes, "Secrets.BackupConfiguration",
!string.IsNullOrEmpty(existing.BackupConfiguration),
HasSecretKey(incoming.Secrets, "BackupConfiguration"));
return BuildItem("DataConnection", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming deployable instance against the existing one. Identity
/// is the <c>UniqueName</c>; the diff is coarse over the template/site/area/state
/// scalar fields plus a name-keyed child diff over the four override/binding
/// collections.
/// <para>
/// The caller is responsible for passing a <b>hydrated</b> <see cref="Instance"/>
/// (its <c>AttributeOverrides</c>, <c>AlarmOverrides</c>, <c>NativeAlarmSourceOverrides</c>,
/// and <c>ConnectionBindings</c> navigation collections eagerly loaded). A
/// non-hydrated entity reads as having no children, which would surface every
/// incoming child as an addition.
/// </para>
/// </summary>
/// <param name="incoming">The incoming instance from the bundle.</param>
/// <param name="existing">The existing (hydrated) instance in the database, or null if new.</param>
/// <param name="existingTemplateName">The existing instance's template name (the entity carries only a FK), or null if unknown.</param>
/// <param name="existingSiteIdentifier">The existing instance's site identifier (the entity carries only a FK), or null if unknown.</param>
/// <param name="existingAreaName">The existing instance's area name (the entity carries only a FK), or null if unknown / unset.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareInstance(
InstanceDto incoming,
Instance? existing,
string? existingTemplateName = null,
string? existingSiteIdentifier = null,
string? existingAreaName = null)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("Instance", incoming.UniqueName);
var changes = new List<FieldChange>();
// The entity stores template/site/area as numeric FKs that can't be compared
// cross-environment; the caller resolves them to names. When a name wasn't
// supplied we skip that scalar rather than emit a spurious "<id:N>" diff.
if (existingTemplateName is not null)
{
AddIfDifferent(changes, "TemplateName", existingTemplateName, incoming.TemplateName);
}
if (existingSiteIdentifier is not null)
{
AddIfDifferent(changes, "SiteIdentifier", existingSiteIdentifier, incoming.SiteIdentifier);
}
AddIfDifferent(changes, "AreaName", existingAreaName, incoming.AreaName);
AddIfDifferent(changes, "State", existing.State.ToString(), incoming.State.ToString());
DiffChildren(
existing.AttributeOverrides,
incoming.AttributeOverrides,
e => e.AttributeName,
i => i.AttributeName,
(e, i) => e.OverrideValue == i.OverrideValue && e.ElementDataType == i.ElementDataType,
"AttributeOverrides",
changes);
DiffChildren(
existing.AlarmOverrides,
incoming.AlarmOverrides,
e => e.AlarmCanonicalName,
i => i.AlarmCanonicalName,
(e, i) => e.TriggerConfigurationOverride == i.TriggerConfigurationOverride
&& e.PriorityLevelOverride == i.PriorityLevelOverride,
"AlarmOverrides",
changes);
DiffChildren(
existing.NativeAlarmSourceOverrides,
incoming.NativeAlarmSourceOverrides,
e => e.SourceCanonicalName,
i => i.SourceCanonicalName,
(e, i) => e.ConnectionNameOverride == i.ConnectionNameOverride
&& e.SourceReferenceOverride == i.SourceReferenceOverride
&& e.ConditionFilterOverride == i.ConditionFilterOverride,
"NativeAlarmSourceOverrides",
changes);
DiffChildren(
existing.ConnectionBindings,
incoming.ConnectionBindings,
e => e.AttributeName,
i => i.AttributeName,
// ConnectionName resolves to a FK on the entity (DataConnectionId) that
// can't be compared cross-environment, so the binding diff compares only
// the per-attribute DataSourceReference override.
(e, i) => e.DataSourceReferenceOverride == i.DataSourceReferenceOverride,
"ConnectionBindings",
changes);
return BuildItem("Instance", incoming.UniqueName, changes);
}
// ---- Helpers ----
private static ImportPreviewItem New(string entityType, string name) =>
@@ -315,15 +468,68 @@ public sealed class ArtifactDiff
private static void AddCodeChangeIfDifferent(List<FieldChange> changes, string field, string? existing, string? incoming)
{
// Script bodies can be large — record a line-count delta + change marker
// instead of inlining the full text so the diff JSON stays compact.
// Code/large-text fields: when they differ, emit a real per-line Myers diff
// (T20). The OldValue/NewValue keep a compact "<N lines>" summary as a
// fallback for renderers that don't consume the structured payload; the
// LineDiff carries the +/- hunk lines. Identical code emits no change.
var sameNullness = existing is null == incoming is null;
var bothPresentAndEqual = sameNullness && (existing is null || string.Equals(existing, incoming, StringComparison.Ordinal));
if (bothPresentAndEqual) return;
changes.Add(BuildCodeFieldChange(field, existing, incoming));
}
/// <summary>
/// Builds a <see cref="FieldChange"/> for a code/large-text field carrying both
/// the compact line-count summary and the structured per-line Myers diff. Only
/// ever called for the named code fields (TemplateScript.Code, SharedScript.Code,
/// ApiMethod.Script) — never for secret-bearing fields — so the verbatim line
/// text emitted here is never sensitive.
/// </summary>
private static FieldChange BuildCodeFieldChange(string field, string? existing, string? incoming)
{
var oldLines = existing?.Split('\n').Length ?? 0;
var newLines = incoming?.Split('\n').Length ?? 0;
changes.Add(new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>"));
// LineDiffer caps at maxLines (default 400); the payload's Truncated flag
// tells the UI when the hunk list was clipped so the JSON stays bounded.
var diff = LineDiffer.Diff(existing, incoming);
return new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>", ToPayload(diff));
}
private static LineDiffPayload ToPayload(LineDiffResult diff)
{
var hunks = new List<LineDiffHunk>(diff.Lines.Count);
foreach (var line in diff.Lines)
{
hunks.Add(new LineDiffHunk(OpName(line.Op), line.Text, line.OldLineNo, line.NewLineNo));
}
return new LineDiffPayload(hunks, diff.Truncated, diff.AddedCount, diff.RemovedCount);
}
private static string OpName(LineDiffOp op) => op switch
{
LineDiffOp.Add => "add",
LineDiffOp.Remove => "remove",
_ => "context",
};
/// <summary>True if <paramref name="secrets"/> carries the named key.</summary>
private static bool HasSecretKey(SecretsBlock? secrets, string key) =>
secrets is not null && secrets.Values.ContainsKey(key);
/// <summary>
/// Records a presence-only secret change (a flip in whether a value is set on
/// each side) using the <c>&lt;present&gt;</c> / null marker convention. The
/// secret value itself is NEVER read into the diff — only its presence.
/// </summary>
private static void AddSecretPresenceChange(List<FieldChange> changes, string field, bool existingHasSecret, bool incomingHasSecret)
{
if (existingHasSecret != incomingHasSecret)
{
changes.Add(new FieldChange(field,
existingHasSecret ? "<present>" : null,
incomingHasSecret ? "<present>" : null));
}
}
private static void DiffChildren<TExisting, TIncoming>(
@@ -392,9 +598,11 @@ public sealed class ArtifactDiff
if (!incomingByName.TryGetValue(name, out var inc)) continue;
if (!ScriptsEqual(ex, inc))
{
var oldLines = ex.Code.Split('\n').Length;
var newLines = inc.Code.Split('\n').Length;
changes.Add(new FieldChange($"Scripts.{name}", $"<{oldLines} lines>", $"<{newLines} lines>"));
// A script row can diverge in Code and/or its trigger/param metadata.
// The structured line diff covers the Code body (T20); when only the
// metadata changed the hunk list is all-context (no +/-), which still
// tells the operator the row changed.
changes.Add(BuildCodeFieldChange($"Scripts.{name}", ex.Code, inc.Code));
}
}
}
@@ -451,7 +659,35 @@ public sealed class ArtifactDiff
private sealed record FieldChange(
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("oldValue")] string? OldValue,
[property: JsonPropertyName("newValue")] string? NewValue);
[property: JsonPropertyName("newValue")] string? NewValue,
// Present only for code / large-text fields (T20). Null (and omitted from
// the JSON by the WhenWritingNull policy) for ordinary coarse fields.
[property: JsonPropertyName("lineDiff")] LineDiffPayload? LineDiff = null);
/// <summary>
/// Serializable projection of <see cref="LineDiffResult"/> embedded inside a
/// code <see cref="FieldChange"/>. <see cref="Hunks"/> is in source order and
/// may be truncated (see <see cref="Truncated"/>); <see cref="AddedCount"/> /
/// <see cref="RemovedCount"/> always report the full diff totals. The UI (E2)
/// renders the +/- view directly from <see cref="Hunks"/>.
/// </summary>
private sealed record LineDiffPayload(
[property: JsonPropertyName("hunks")] IReadOnlyList<LineDiffHunk> Hunks,
[property: JsonPropertyName("truncated")] bool Truncated,
[property: JsonPropertyName("addedCount")] int AddedCount,
[property: JsonPropertyName("removedCount")] int RemovedCount);
/// <summary>
/// One emitted line of a code line-diff. <see cref="Op"/> is the lower-case
/// op name (<c>context</c> / <c>add</c> / <c>remove</c>); line numbers are
/// 1-based and null where the side does not participate (an <c>add</c> has no
/// old line number, a <c>remove</c> has no new line number).
/// </summary>
private sealed record LineDiffHunk(
[property: JsonPropertyName("op")] string Op,
[property: JsonPropertyName("text")] string Text,
[property: JsonPropertyName("oldLineNo")] int? OldLineNo,
[property: JsonPropertyName("newLineNo")] int? NewLineNo);
private sealed record FieldDiff(
[property: JsonPropertyName("adds")] IReadOnlyList<string> Adds,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,299 @@
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// The kind of edit a <see cref="LineDiffLine"/> represents within a per-line
/// diff: an unchanged line common to both sides (<see cref="Context"/>), a line
/// present only in the new text (<see cref="Add"/>), or a line present only in
/// the old text (<see cref="Remove"/>).
/// </summary>
public enum LineDiffOp
{
/// <summary>Unchanged line, present in both old and new text.</summary>
Context,
/// <summary>Line present only in the new text.</summary>
Add,
/// <summary>Line present only in the old text.</summary>
Remove,
}
/// <summary>
/// One line of a per-line diff. <see cref="OldLineNo"/> and <see cref="NewLineNo"/>
/// are 1-based. <see cref="LineDiffOp.Context"/> lines carry both; <see cref="LineDiffOp.Add"/>
/// lines carry only <see cref="NewLineNo"/>; <see cref="LineDiffOp.Remove"/> lines carry
/// only <see cref="OldLineNo"/>.
/// </summary>
public sealed record LineDiffLine(LineDiffOp Op, string Text, int? OldLineNo, int? NewLineNo);
/// <summary>
/// The result of a per-line diff. <see cref="Lines"/> is in source order and may
/// be truncated (see <see cref="Truncated"/>); <see cref="AddedCount"/> and
/// <see cref="RemovedCount"/> always report the full diff totals regardless of
/// truncation.
/// </summary>
public sealed record LineDiffResult(
IReadOnlyList<LineDiffLine> Lines,
bool Truncated,
int AddedCount,
int RemovedCount);
/// <summary>
/// Pure, dependency-free per-line text diff using the Myers O(ND) shortest-edit-script
/// algorithm. Line endings are normalized (<c>\r\n</c> and lone <c>\r</c> become <c>\n</c>)
/// before splitting, so otherwise-identical text with mixed endings produces no changes.
/// </summary>
/// <remarks>
/// Hand-rolled deliberately — the project forbids third-party diff libraries (mirroring
/// the custom-SVG <c>KpiTrendChart</c> precedent). The classic Myers algorithm computes a
/// minimal edit script; we record the search frontier per edit-distance <c>d</c> and
/// backtrack to recover that script, then emit lines in source order.
/// </remarks>
public static class LineDiffer
{
/// <summary>
/// Computes a minimal per-line diff between <paramref name="oldText"/> and
/// <paramref name="newText"/> (either may be <see langword="null"/>, treated as empty).
/// </summary>
/// <param name="oldText">The original text, or <see langword="null"/> (empty).</param>
/// <param name="newText">The new text, or <see langword="null"/> (empty).</param>
/// <param name="maxLines">
/// Maximum number of entries to retain in <see cref="LineDiffResult.Lines"/>. If the full
/// diff would emit more lines, <see cref="LineDiffResult.Lines"/> is truncated to this many
/// entries and <see cref="LineDiffResult.Truncated"/> is set; the add/remove totals are
/// unaffected. Values &lt;= 0 yield an empty <see cref="LineDiffResult.Lines"/>.
/// </param>
public static LineDiffResult Diff(string? oldText, string? newText, int maxLines = 400)
{
string[] oldLines = SplitLines(oldText);
string[] newLines = SplitLines(newText);
IReadOnlyList<LineDiffLine> full = BuildDiff(oldLines, newLines, out int added, out int removed);
bool truncated = full.Count > maxLines;
IReadOnlyList<LineDiffLine> lines;
if (truncated)
{
int take = maxLines > 0 ? maxLines : 0;
var capped = new LineDiffLine[take];
for (int i = 0; i < take; i++)
{
capped[i] = full[i];
}
lines = capped;
}
else
{
lines = full;
}
return new LineDiffResult(lines, truncated, added, removed);
}
/// <summary>
/// Normalizes line endings (<c>\r\n</c> and lone <c>\r</c> to <c>\n</c>) and splits on <c>\n</c>.
/// A <see langword="null"/> or empty input yields an empty array (no lines), so the diff of two
/// empties is empty rather than a single empty-string match.
/// </summary>
private static string[] SplitLines(string? text)
{
if (string.IsNullOrEmpty(text))
{
return [];
}
string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
return normalized.Split('\n');
}
/// <summary>
/// Runs Myers O(ND), backtracks the recorded frontier into a source-ordered edit script,
/// and reports the full add/remove totals via the out parameters.
/// </summary>
private static IReadOnlyList<LineDiffLine> BuildDiff(
string[] a,
string[] b,
out int added,
out int removed)
{
int n = a.Length;
int m = b.Length;
var result = new List<LineDiffLine>(n + m);
added = 0;
removed = 0;
// Fast paths: one side empty.
if (n == 0 && m == 0)
{
return result;
}
if (n == 0)
{
for (int j = 0; j < m; j++)
{
result.Add(new LineDiffLine(LineDiffOp.Add, b[j], OldLineNo: null, NewLineNo: j + 1));
}
added = m;
return result;
}
if (m == 0)
{
for (int i = 0; i < n; i++)
{
result.Add(new LineDiffLine(LineDiffOp.Remove, a[i], OldLineNo: i + 1, NewLineNo: null));
}
removed = n;
return result;
}
// Myers shortest-edit-script: record the V frontier at each edit distance d so we can
// backtrack the path. The diagonal index k ranges over [-d, d]; we offset by `max` to
// index a non-negative array.
int max = n + m;
int offset = max;
var trace = new List<int[]>(max + 1);
var v = new int[(2 * max) + 1];
int foundD = -1;
for (int d = 0; d <= max; d++)
{
// Snapshot V before this round's overwrites so backtracking is faithful.
trace.Add((int[])v.Clone());
for (int k = -d; k <= d; k += 2)
{
int x;
if (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1]))
{
// Down move (insertion): take the path from k+1.
x = v[offset + k + 1];
}
else
{
// Right move (deletion): extend the path from k-1.
x = v[offset + k - 1] + 1;
}
int y = x - k;
// Follow the diagonal (matching lines) as far as possible.
while (x < n && y < m && a[x] == b[y])
{
x++;
y++;
}
v[offset + k] = x;
if (x >= n && y >= m)
{
foundD = d;
break;
}
}
if (foundD >= 0)
{
break;
}
}
// Backtrack the recorded frontier to produce the edit script in reverse, then reverse it.
var reversed = Backtrack(a, b, trace, foundD, offset, n, m);
foreach (LineDiffLine line in reversed)
{
result.Add(line);
if (line.Op == LineDiffOp.Add)
{
added++;
}
else if (line.Op == LineDiffOp.Remove)
{
removed++;
}
}
return result;
}
/// <summary>
/// Walks the per-distance V snapshots backwards from the end point, emitting matched
/// (context), removed, and added lines. The walk produces lines in reverse source order;
/// the returned list is reversed back into forward source order.
/// </summary>
private static List<LineDiffLine> Backtrack(
string[] a,
string[] b,
List<int[]> trace,
int foundD,
int offset,
int n,
int m)
{
var reverse = new List<LineDiffLine>(n + m);
int x = n;
int y = m;
for (int d = foundD; d > 0; d--)
{
int[] v = trace[d];
int k = x - y;
int prevK;
if (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1]))
{
// Came from a down move (insertion).
prevK = k + 1;
}
else
{
// Came from a right move (deletion).
prevK = k - 1;
}
int prevX = v[offset + prevK];
int prevY = prevX - prevK;
// Emit the diagonal (matched lines) that follows the snake's start.
while (x > prevX && y > prevY)
{
x--;
y--;
reverse.Add(new LineDiffLine(LineDiffOp.Context, a[x], OldLineNo: x + 1, NewLineNo: y + 1));
}
if (d > 0)
{
if (x == prevX)
{
// Insertion: a new-side line (b[prevY]) with no old counterpart.
y--;
reverse.Add(new LineDiffLine(LineDiffOp.Add, b[y], OldLineNo: null, NewLineNo: y + 1));
}
else
{
// Deletion: an old-side line (a[prevX]) with no new counterpart.
x--;
reverse.Add(new LineDiffLine(LineDiffOp.Remove, a[x], OldLineNo: x + 1, NewLineNo: null));
}
}
}
// d == 0 leg: any remaining leading diagonal is all matched context.
while (x > 0 && y > 0)
{
x--;
y--;
reverse.Add(new LineDiffLine(LineDiffOp.Context, a[x], OldLineNo: x + 1, NewLineNo: y + 1));
}
reverse.Reverse();
return reverse;
}
}
@@ -1,7 +1,9 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -24,7 +26,15 @@ public sealed record EntityAggregate(
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
IReadOnlyList<NotificationList> NotificationLists,
IReadOnlyList<SmtpConfiguration> SmtpConfigurations,
IReadOnlyList<ApiMethod> ApiMethods);
IReadOnlyList<ApiMethod> ApiMethods)
{
// M8: site/instance-scoped entities. Init-only with empty-array defaults so
// existing positional `new EntityAggregate(...)` callers keep compiling and
// never see a null collection; new callers opt in via object-initializer.
public IReadOnlyList<Site> Sites { get; init; } = Array.Empty<Site>();
public IReadOnlyList<DataConnection> DataConnections { get; init; } = Array.Empty<DataConnection>();
public IReadOnlyList<Instance> Instances { get; init; } = Array.Empty<Instance>();
}
/// <summary>
/// Top-level serializable bundle payload. Lists are sequenced in dependency
@@ -50,7 +60,24 @@ public sealed record BundleContentDto(
IReadOnlyList<NotificationListDto> NotificationLists,
IReadOnlyList<SmtpConfigDto> SmtpConfigs,
IReadOnlyList<ApiMethodDto> ApiMethods,
IReadOnlyList<ApiKeyDto>? ApiKeys = null);
IReadOnlyList<ApiKeyDto>? ApiKeys = null)
{
// M8: site/instance-scoped payloads. Modeled as init-only properties with
// empty-array defaults (rather than additional positional ctor params) for
// two reasons:
// 1. Forward-compat: deserializing an OLDER bundle whose content blob has
// no sites/dataConnections/instances fields leaves each property at its
// Array.Empty<>() initializer — so consumers always see an empty list,
// never null. (Contrast ApiKeys, which is a nullable legacy field.)
// 2. Source-compat: every existing `new BundleContentDto(...)` positional
// caller keeps compiling unchanged; new producers opt in via an
// object-initializer (`new BundleContentDto(...) { Sites = ... }`).
// These are written by the serializer (WhenWritingNull does not apply — they
// are non-null), so new bundles always carry the three arrays explicitly.
public IReadOnlyList<SiteDto> Sites { get; init; } = Array.Empty<SiteDto>();
public IReadOnlyList<DataConnectionDto> DataConnections { get; init; } = Array.Empty<DataConnectionDto>();
public IReadOnlyList<InstanceDto> Instances { get; init; } = Array.Empty<InstanceDto>();
}
/// <summary>
/// Carved-off secret values for an entity. The outer DTO carries all non-
@@ -175,3 +202,71 @@ public sealed record ApiMethodDto(
string? ParameterDefinitions,
string? ReturnDefinition,
int TimeoutSeconds);
// --- M8: site/instance-scoped transport DTOs --------------------------------
// These travel alongside the original central-config DTOs above. Sites and
// instances are referenced by their stable string identities (SiteIdentifier,
// UniqueName, TemplateName) rather than database ids so a bundle resolves
// correctly against the *target* environment's own surrogate keys.
/// <summary>
/// A site definition. Addresses are carried verbatim; the importer decides
/// whether to keep or rewrite them for the target environment.
/// </summary>
public sealed record SiteDto(
string SiteIdentifier,
string Name,
string? Description,
string? NodeAAddress,
string? NodeBAddress,
string? GrpcNodeAAddress,
string? GrpcNodeBAddress);
/// <summary>
/// A site-scoped protocol connection (the <c>Sites.DataConnection</c> entity —
/// NOT the External-System <see cref="DatabaseConnectionDto"/>). The protocol-
/// specific Primary/Backup configuration JSON rides inside <see cref="Secrets"/>
/// so a "share without secrets" export can drop it as a unit.
/// </summary>
public sealed record DataConnectionDto(
string SiteIdentifier,
string Name,
string Protocol,
int FailoverRetryCount,
SecretsBlock? Secrets);
public sealed record InstanceAttributeOverrideDto(
string AttributeName,
string? OverrideValue,
DataType? ElementDataType);
public sealed record InstanceAlarmOverrideDto(
string AlarmCanonicalName,
string? TriggerConfigurationOverride,
int? PriorityLevelOverride);
public sealed record InstanceNativeAlarmSourceOverrideDto(
string SourceCanonicalName,
string? ConnectionNameOverride,
string? SourceReferenceOverride,
string? ConditionFilterOverride);
public sealed record InstanceConnectionBindingDto(
string AttributeName,
string ConnectionName,
string? DataSourceReferenceOverride);
/// <summary>
/// A deployable instance with all of its per-instance overrides and bindings.
/// References its template and site by name/identifier (not id).
/// </summary>
public sealed record InstanceDto(
string UniqueName,
string TemplateName,
string SiteIdentifier,
string? AreaName,
InstanceState State,
IReadOnlyList<InstanceAttributeOverrideDto> AttributeOverrides,
IReadOnlyList<InstanceAlarmOverrideDto> AlarmOverrides,
IReadOnlyList<InstanceNativeAlarmSourceOverrideDto> NativeAlarmSourceOverrides,
IReadOnlyList<InstanceConnectionBindingDto> ConnectionBindings);
@@ -1,7 +1,9 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
@@ -27,6 +29,13 @@ public sealed class EntitySerializer
var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);
// M8 name-link lookups. Instances/data-connections reference their owning
// site by the portable SiteIdentifier (not the EF surrogate SiteId); a
// connection binding references its DataConnection by Name. Build both
// id→name maps once so the projections below resolve in O(1).
var siteIdentifierBySiteId = aggregate.Sites.ToDictionary(s => s.Id, s => s.SiteIdentifier);
var connectionNameByConnectionId = aggregate.DataConnections.ToDictionary(c => c.Id, c => c.Name);
return new BundleContentDto(
TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto(
Name: f.Name,
@@ -165,7 +174,74 @@ public sealed class EntitySerializer
Script: m.Script,
ParameterDefinitions: m.ParameterDefinitions,
ReturnDefinition: m.ReturnDefinition,
TimeoutSeconds: m.TimeoutSeconds)).ToList());
TimeoutSeconds: m.TimeoutSeconds)).ToList())
{
// M8 site/instance-scoped payloads. These are init-only (non-positional)
// on BundleContentDto, so they're set via object-initializer here.
Sites = aggregate.Sites.Select(s => new SiteDto(
SiteIdentifier: s.SiteIdentifier,
Name: s.Name,
Description: s.Description,
NodeAAddress: s.NodeAAddress,
NodeBAddress: s.NodeBAddress,
GrpcNodeAAddress: s.GrpcNodeAAddress,
GrpcNodeBAddress: s.GrpcNodeBAddress)).ToList(),
DataConnections = aggregate.DataConnections.Select(c =>
{
// The protocol-specific Primary/Backup configuration JSON typically
// embeds endpoints + credentials, so the whole blob rides inside the
// SecretsBlock (a "share without secrets" export drops it as a unit).
// Omit a key entirely when its source value is null/empty so the
// importer's TryGetValue cleanly yields "no value" rather than "".
var secretValues = new Dictionary<string, string>(StringComparer.Ordinal);
if (!string.IsNullOrEmpty(c.PrimaryConfiguration))
{
secretValues["PrimaryConfiguration"] = c.PrimaryConfiguration;
}
if (!string.IsNullOrEmpty(c.BackupConfiguration))
{
secretValues["BackupConfiguration"] = c.BackupConfiguration;
}
return new DataConnectionDto(
SiteIdentifier: siteIdentifierBySiteId.TryGetValue(c.SiteId, out var sid) ? sid : string.Empty,
Name: c.Name,
Protocol: c.Protocol,
FailoverRetryCount: c.FailoverRetryCount,
Secrets: secretValues.Count > 0 ? new SecretsBlock(secretValues) : null);
}).ToList(),
Instances = aggregate.Instances.Select(inst => new InstanceDto(
UniqueName: inst.UniqueName,
TemplateName: templateNameById.TryGetValue(inst.TemplateId, out var tn) ? tn : string.Empty,
SiteIdentifier: siteIdentifierBySiteId.TryGetValue(inst.SiteId, out var isid) ? isid : string.Empty,
// TODO(M8): Areas don't travel in the bundle yet — EntityAggregate
// carries no Areas collection, so there's no id→name lookup. Emit
// null until area transport is added; the importer leaves AreaId null.
AreaName: null,
State: inst.State,
AttributeOverrides: inst.AttributeOverrides.Select(o => new InstanceAttributeOverrideDto(
AttributeName: o.AttributeName,
OverrideValue: o.OverrideValue,
ElementDataType: o.ElementDataType)).ToList(),
AlarmOverrides: inst.AlarmOverrides.Select(o => new InstanceAlarmOverrideDto(
AlarmCanonicalName: o.AlarmCanonicalName,
TriggerConfigurationOverride: o.TriggerConfigurationOverride,
PriorityLevelOverride: o.PriorityLevelOverride)).ToList(),
NativeAlarmSourceOverrides: inst.NativeAlarmSourceOverrides.Select(o => new InstanceNativeAlarmSourceOverrideDto(
SourceCanonicalName: o.SourceCanonicalName,
ConnectionNameOverride: o.ConnectionNameOverride,
SourceReferenceOverride: o.SourceReferenceOverride,
ConditionFilterOverride: o.ConditionFilterOverride)).ToList(),
// Connection bindings reference their DataConnection by NAME — the
// numeric DataConnectionId FK can't survive a cross-environment
// bundle, so the importer (D1) resolves ConnectionName→target FK at
// apply time. If the FK doesn't resolve in this aggregate (orphan
// row), the name comes through empty.
ConnectionBindings: inst.ConnectionBindings.Select(b => new InstanceConnectionBindingDto(
AttributeName: b.AttributeName,
ConnectionName: connectionNameByConnectionId.TryGetValue(b.DataConnectionId, out var cn) ? cn : string.Empty,
DataSourceReferenceOverride: b.DataSourceReferenceOverride)).ToList())).ToList(),
};
}
/// <summary>Reconstructs a persistence-shaped <see cref="EntityAggregate"/> from a wire-shaped <see cref="BundleContentDto"/>.</summary>
@@ -352,6 +428,100 @@ public sealed class EntitySerializer
})
.ToList();
// M8: sites first — assign synthetic ids by ordinal. Addresses are carried
// verbatim; the importer decides whether to keep or rewrite them.
var sites = content.Sites
.Select((dto, ix) => new Site(dto.Name, dto.SiteIdentifier)
{
Id = ix + 1,
Description = dto.Description,
NodeAAddress = dto.NodeAAddress,
NodeBAddress = dto.NodeBAddress,
GrpcNodeAAddress = dto.GrpcNodeAAddress,
GrpcNodeBAddress = dto.GrpcNodeBAddress,
})
.ToList();
var siteIdByIdentifier = sites.ToDictionary(s => s.SiteIdentifier, s => s.Id, StringComparer.Ordinal);
// Data connections: resolve owning site by SiteIdentifier, restore the
// protocol config from the SecretsBlock (null when the key was omitted).
var dataConnections = content.DataConnections
.Select((dto, ix) => new DataConnection(
dto.Name,
dto.Protocol,
siteIdByIdentifier.TryGetValue(dto.SiteIdentifier, out var sid) ? sid : 0)
{
Id = ix + 1,
FailoverRetryCount = dto.FailoverRetryCount,
PrimaryConfiguration = dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null,
BackupConfiguration = dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null,
})
.ToList();
// Instances: resolve template + site by name/identifier. Areas don't travel
// (see export TODO), so AreaId stays null. Connection bindings keep their
// resolved DataConnection NAME on the wire DTO — the importer (D1) reads
// bindings from BundleContentDto.Instances[].ConnectionBindings directly and
// resolves ConnectionName→the target environment's DataConnectionId at apply
// time. FromBundleContent is NOT on the importer's path (BundleImporter walks
// the raw DTO), so here we materialize the binding entity with a placeholder
// DataConnectionId = 0; it carries AttributeName + DataSourceReferenceOverride
// so a round-trip preserves the binding shape, but the FK is intentionally
// unresolved (there is no DataConnection-name→id map valid for the target DB).
var instances = content.Instances
.Select((dto, ix) =>
{
var inst = new Instance(dto.UniqueName)
{
Id = ix + 1,
State = dto.State,
TemplateId = templateIdByName.TryGetValue(dto.TemplateName, out var tid) ? tid : 0,
SiteId = siteIdByIdentifier.TryGetValue(dto.SiteIdentifier, out var isid) ? isid : 0,
AreaId = null,
};
foreach (var o in dto.AttributeOverrides)
{
inst.AttributeOverrides.Add(new InstanceAttributeOverride(o.AttributeName)
{
InstanceId = inst.Id,
OverrideValue = o.OverrideValue,
ElementDataType = o.ElementDataType,
});
}
foreach (var o in dto.AlarmOverrides)
{
inst.AlarmOverrides.Add(new InstanceAlarmOverride(o.AlarmCanonicalName)
{
InstanceId = inst.Id,
TriggerConfigurationOverride = o.TriggerConfigurationOverride,
PriorityLevelOverride = o.PriorityLevelOverride,
});
}
foreach (var o in dto.NativeAlarmSourceOverrides)
{
inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride(o.SourceCanonicalName)
{
InstanceId = inst.Id,
ConnectionNameOverride = o.ConnectionNameOverride,
SourceReferenceOverride = o.SourceReferenceOverride,
ConditionFilterOverride = o.ConditionFilterOverride,
});
}
foreach (var b in dto.ConnectionBindings)
{
inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
{
InstanceId = inst.Id,
// FK left at 0 on purpose — the importer resolves it from the
// DTO's ConnectionName against target-DB DataConnection ids.
DataConnectionId = 0,
DataSourceReferenceOverride = b.DataSourceReferenceOverride,
});
}
return inst;
})
.ToList();
return new EntityAggregate(
TemplateFolders: folders,
Templates: templates,
@@ -361,6 +531,11 @@ public sealed class EntitySerializer
DatabaseConnections: databaseConnections,
NotificationLists: notificationLists,
SmtpConfigurations: smtpConfigurations,
ApiMethods: apiMethods);
ApiMethods: apiMethods)
{
Sites = sites,
DataConnections = dataConnections,
Instances = instances,
};
}
}
@@ -12,7 +12,12 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
public sealed class ManifestBuilder
{
public const int CurrentBundleFormatVersion = 1;
public const string CurrentSchemaVersion = "1.0";
// Schema minor version. Bumped 1.0 → 1.1 in M8 to carry the additive
// site/dataConnection/instance summary counts (and their content arrays).
// Minor bumps are additive-only: ManifestValidator gates solely on
// BundleFormatVersion, so an older "1.0" bundle still validates Ok.
public const string CurrentSchemaVersion = "1.1";
/// <summary>
/// Builds a <see cref="BundleManifest"/> with the current format version, a SHA-256 content hash,
+4 -4
View File
@@ -47,7 +47,7 @@ Real, but narrower than the spec — wrong in a way that could surprise an opera
| **13** | **Inbound `Object`/`List` extended types are shape-validated only** — no nested/field-level type validation, despite spec implying typed/nested validation. | `ParameterValidator.cs:109-145`; `ReturnValueValidator.cs:18` |
| **14** | **JWT-in-cookie session design not implemented** — `/auth/login` signs a plain `ClaimsPrincipal`; `GenerateToken` only used by the CLI `/auth/token` path; `ValidateToken` has no external callers. | `AuthEndpoints.cs:38,75-112,152`; `ServiceCollectionExtensions.cs:99-118` |
| **15** | **"Re-query LDAP every 15 min / roles never >15 min stale" not implemented for interactive sessions** — `JwtTokenService.RefreshToken`/`RecordActivity`/`ShouldRefresh`/`IsIdleTimedOut` have **zero** call sites; roles fixed until cookie expiry. The 15-min sliding + 30-min idle layers are collapsed into a single 30-min sliding cookie window. | `JwtTokenService.*` (no callers); `ServiceCollectionExtensions.cs:99-148` |
| **16** | **Transport stale-instance enumeration always returns empty** — `BundleImporter` returns `Array.Empty<int>()`; UI shows a generic warning with no count, link not filtered to stale instances. | `BundleImporter.cs:733`; `TransportImport.razor:347-388` |
| **16** | **FIXED (M8/T18).** Transport stale-instance enumeration is real: before commit, for each Overwritten template `BundleImporter` enumerates deployed target instances whose freshly-flattened revision hash drifts from `DeployedConfigSnapshot.RevisionHash` via the new `IStaleInstanceProbe` seam (Commons; implemented in DeploymentManager) and returns their ids; Confirm shows the real count and the result deep-links the filtered Deployments page. Was `Array.Empty<int>()`. | `BundleImporter.cs` (`ComputeStaleInstanceIdsAsync`); `IStaleInstanceProbe.cs`; `StaleInstanceProbe.cs`; `TransportImport.razor` |
| **17** | **`MachineDataDb` fail-fast requirement not enforced** — spec (REQ-HOST-3/4) requires central nodes to validate a non-empty `MachineDataDb` connection string. `DatabaseOptions` has only `ConfigurationDb`/`SiteDbPath`; validator never checks it; 0 `grep` hits in `src/`. Key lives only in docker appsettings as dead config. | `DatabaseOptions.cs:6-12`; `StartupValidator.cs:60-61` |
| **18** | **CI grep-guard against `UPDATE/DELETE … AuditLog` not in the repo** — spec claims a build-time grep that fails on data-layer mutations. DB-role DENY enforcement *is* present in migrations (so this is a backstop, not the only control), but the claimed code-level guard is absent. | spec `Component-AuditLog.md:335-336`, `Component-ConfigurationDatabase.md:297` |
@@ -98,9 +98,9 @@ Knowingly punted, with extensible seams and explicit doc notes. `[PERM]` = perma
- `[DELIVERED M7/T17]` OPC UA "Verify endpoint" connectivity button (captures-but-never-trusts an untrusted cert) + site-local cert-management UI (per-node `CertStoreActor`, DeploymentManager broadcast to both nodes). *Follow-up:* central-persisted, auditable cert trust (site-local today).
**Transport (#24)**
- `[PERM]` Site-scoped / instance-scoped artifact transport (needs name-mapping subsystem).
- `[PERM]` Direct cluster-to-cluster pull; asymmetric bundle signing; differential/incremental bundles.
- `[PERM/SLICE]` Per-line/Myers diff for Modified artifacts (coarse line-count delta only). — `ArtifactDiff.cs:18-25`
- `[DELIVERED M8/T18]` Site-scoped / instance-scoped artifact transport with the `BundleNameMap` name-mapping subsystem. Moves `Site`s, site-scoped `DataConnection`s, and `Instance`s (+ override children + `Area` by name); auto-match (sites by `SiteIdentifier`, connections by `{SiteIdentifier}/{Name}`, instances by `UniqueName`) with operator override via the import-wizard Map step / CLI `--map-site`/`--map-connection`/`--create-missing-*`; D3 carry-full-config (Site addresses travel; `DataConnection` config rides the encrypted `SecretsBlock`, presence-only in diffs); schema 1.1 (additive). *Follow-ups:* DeploymentManagerRepository hydration; large-bundle/perf hardening.
- `[DELIVERED M8/T20]` Per-line/Myers diff for Modified code fields — pure `LineDiffer` (custom Myers O(ND), no third-party lib); `ArtifactDiff` embeds a size-capped structured `lineDiff` (hunks + `truncated` + add/remove totals); rendered by the `LineDiffView` import-wizard component.
- `[PERM]` Direct cluster-to-cluster pull; asymmetric bundle signing; differential/incremental bundles. — **deferred** (T19, its own brainstorm).
**TreeView**
- `[SLICE/PERM]` R6 lazy-loading, R7 keyboard nav, R16 multi-select — spec marks all "(Deferred)". — `Component-TreeView.md:87-93,288-295`
@@ -1,3 +1,4 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
@@ -91,4 +92,145 @@ public class BundleCommandsStreamingTests : IDisposable
{
Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty));
}
// ---- M8 (B4): --sites / --instances comma-split + flag parsing ----------
[Fact]
public void ParseNameList_CommaSeparated_SplitsAndTrims()
{
var result = BundleCommands.ParseNameList(" North-01 , East-02 ,West-03 ");
Assert.NotNull(result);
Assert.Equal(new[] { "North-01", "East-02", "West-03" }, result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ParseNameList_NullOrBlank_ReturnsNull(string? token)
{
Assert.Null(BundleCommands.ParseNameList(token));
}
[Fact]
public void ParseNameList_EmptyEntries_AreDropped()
{
var result = BundleCommands.ParseNameList("a,,b, ,c");
Assert.Equal(new[] { "a", "b", "c" }, result);
}
[Fact]
public void BundleExport_SitesAndInstancesFlags_ParseWithoutError()
{
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var username = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
var bundle = BundleCommands.Build(url, format, username, password);
var parse = bundle.Parse(new[]
{
"export", "--output", "/tmp/out.scadabundle",
"--sites", "NORTH-01,East Plant",
"--instances", "NORTH-01.Pump1,NORTH-01.Pump2",
});
Assert.Empty(parse.Errors);
}
// ---- M8 (D3): --map-site / --map-connection spec parsing ----------------
[Fact]
public void ParseSiteMappings_Null_ReturnsEmpty()
{
Assert.Empty(BundleCommands.ParseSiteMappings(null));
}
[Fact]
public void ParseSiteMappings_SrcEqualsDst_MapsToExisting()
{
var specs = BundleCommands.ParseSiteMappings(new[] { "NORTH-01=PLANT-A" });
var spec = Assert.Single(specs);
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
Assert.Equal("PLANT-A", spec.TargetSiteIdentifier);
}
[Theory]
[InlineData("NORTH-01")] // no '=' part -> create-new
[InlineData("NORTH-01=")] // empty rhs -> create-new
[InlineData("NORTH-01= ")] // whitespace rhs -> create-new
public void ParseSiteMappings_NoTarget_MeansCreateNew(string token)
{
var specs = BundleCommands.ParseSiteMappings(new[] { token });
var spec = Assert.Single(specs);
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
Assert.Null(spec.TargetSiteIdentifier);
}
[Fact]
public void ParseSiteMappings_Repeated_AccumulatesAll()
{
var specs = BundleCommands.ParseSiteMappings(new[] { "A=X", "B" });
Assert.Equal(2, specs.Count);
Assert.Equal("X", specs[0].TargetSiteIdentifier);
Assert.Null(specs[1].TargetSiteIdentifier);
}
[Fact]
public void ParseSiteMappings_EmptySource_Throws()
{
Assert.Throws<FormatException>(() => BundleCommands.ParseSiteMappings(new[] { "=PLANT-A" }));
}
[Fact]
public void ParseConnectionMappings_SrcEqualsDst_MapsToExisting()
{
var specs = BundleCommands.ParseConnectionMappings(new[] { "NORTH-01/OpcA=OpcLive" });
var spec = Assert.Single(specs);
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
Assert.Equal("OpcA", spec.SourceConnectionName);
Assert.Equal("OpcLive", spec.TargetConnectionName);
}
[Theory]
[InlineData("NORTH-01/OpcA")] // no '=' part -> create-new
[InlineData("NORTH-01/OpcA=")] // empty rhs -> create-new
public void ParseConnectionMappings_NoTarget_MeansCreateNew(string token)
{
var specs = BundleCommands.ParseConnectionMappings(new[] { token });
var spec = Assert.Single(specs);
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
Assert.Equal("OpcA", spec.SourceConnectionName);
Assert.Null(spec.TargetConnectionName);
}
[Theory]
[InlineData("OpcA")] // missing site/name shape
[InlineData("NORTH-01/")] // empty connection name
[InlineData("/OpcA")] // empty site
public void ParseConnectionMappings_MalformedSource_Throws(string token)
{
Assert.Throws<FormatException>(() => BundleCommands.ParseConnectionMappings(new[] { token }));
}
[Fact]
public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError()
{
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var username = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
var bundle = BundleCommands.Build(url, format, username, password);
var parse = bundle.Parse(new[]
{
"import", "--input", "/tmp/in.scadabundle",
"--map-site", "NORTH-01=PLANT-A",
"--map-connection", "NORTH-01/OpcA=OpcLive",
"--create-missing-sites",
"--create-missing-connections",
});
Assert.Empty(parse.Errors);
}
}
@@ -7,8 +7,10 @@ using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
@@ -49,6 +51,7 @@ public class TransportExportPageTests : BunitContext
private readonly IExternalSystemRepository _externalRepo = Substitute.For<IExternalSystemRepository>();
private readonly INotificationRepository _notificationRepo = Substitute.For<INotificationRepository>();
private readonly IInboundApiRepository _inboundApiRepo = Substitute.For<IInboundApiRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>();
public TransportExportPageTests()
@@ -73,14 +76,24 @@ public class TransportExportPageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
// Empty site/instance defaults — the M8 site/instance picker calls these on load
// and the DependencyResolver fans out over sites on resolve.
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
_siteRepo.GetInstancesBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>()));
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(new List<DataConnection>()));
Services.AddSingleton(_templateRepo);
Services.AddSingleton(_externalRepo);
Services.AddSingleton(_notificationRepo);
Services.AddSingleton(_inboundApiRepo);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_exporter);
// DependencyResolver is sealed but its only dependencies are the four
// repositories above — registering the concrete type is enough.
// DependencyResolver is sealed but its dependencies are the repositories above
// (template/external/notification/inbound + ISiteRepository for M8 site/instance
// export) — registering the concrete type is enough.
Services.AddSingleton<DependencyResolver>();
Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
@@ -312,6 +325,108 @@ public class TransportExportPageTests : BunitContext
Assert.False(result.Succeeded);
}
// ─────────────────────────────────────────────────────────────────────
// Test 5 (M8 E1): Step 1 renders the Sites & Instances group, listing each
// site and (when expanded) its instances.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void Renders_step1_sites_group_with_sites_and_instances()
{
var site = new Site("North Plant", "north") { Id = 5 };
var instance = new Instance("north/pump-01") { Id = 50, SiteId = 5 };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { site }));
_siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { instance }));
var cut = Render<TransportExportPage>();
cut.WaitForState(() => cut.Markup.Contains("North Plant"));
// The Sites & Instances group is present alongside the existing artifact groups.
Assert.NotNull(cut.Find("[data-testid='group-sites']"));
Assert.NotNull(cut.Find("[data-testid='sites-hint']"));
Assert.Contains("North Plant", cut.Markup);
Assert.Contains("north", cut.Markup);
// The site row carries a site checkbox; expanding reveals the instance checkbox.
Assert.NotNull(cut.Find("#chk-site-5"));
var expandToggle = cut.Find("[data-testid='site-row'] button");
expandToggle.Click();
cut.WaitForAssertion(() =>
{
Assert.NotNull(cut.Find("[data-testid='site-instances']"));
Assert.NotNull(cut.Find("#chk-instance-50"));
Assert.Contains("north/pump-01", cut.Markup);
});
}
// ─────────────────────────────────────────────────────────────────────
// Test 6 (M8 E1): Selecting a site flows its id into ExportSelection.SiteIds;
// selecting an individual instance flows its id into InstanceIds. Verified at
// the resolver/exporter boundary (the same contract DependencyResolver reads).
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Step4_export_carries_selected_site_and_instance_ids()
{
var siteA = new Site("North Plant", "north") { Id = 5 };
var siteB = new Site("South Plant", "south") { Id = 6 };
var instanceB = new Instance("south/pump-09") { Id = 90, SiteId = 6 };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { siteA, siteB }));
_siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>()));
_siteRepo.GetInstancesBySiteIdAsync(6, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { instanceB }));
// Resolver fetches selected entities by id; return them so the closure is non-empty.
_siteRepo.GetSiteByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Site?>(siteA));
_exporter
.ExportAsync(
Arg.Any<ExportSelection>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));
var cut = Render<TransportExportPage>();
cut.WaitForState(() => cut.Markup.Contains("North Plant"));
// Tick site 5 (North Plant) directly.
cut.Find("#chk-site-5").Change(true);
// Expand South Plant and tick its instance individually.
var southRow = cut.FindAll("[data-testid='site-row']")
.First(r => r.GetAttribute("data-site-id") == "6");
southRow.QuerySelector("button")!.Click();
cut.WaitForState(() => cut.FindAll("#chk-instance-90").Count > 0);
cut.Find("#chk-instance-90").Change(true);
// Step 1 → 2 → 3 → export.
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));
cut.Find("#passphrase").Input("hunter2hunter2");
cut.Find("#passphrase-confirm").Input("hunter2hunter2");
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));
await _exporter.Received(1).ExportAsync(
Arg.Is<ExportSelection>(s =>
s.SiteIds.Contains(5)
&& s.InstanceIds.Contains(90)),
"alice",
"test-cluster",
"hunter2hunter2",
Arg.Any<CancellationToken>());
}
// ─────────────────────────────────────────────────────────────────────
// Static helpers — exercised directly so the file-naming + secret-count
// contract is unit-pinned independently of the rendering surface.
@@ -323,4 +438,80 @@ public class TransportExportPageTests : BunitContext
var filename = TransportExportPage.BuildFilename("dev/cluster a", fixedTime);
Assert.Equal("scadabundle-dev-cluster-a-2026-05-24-134522.scadabundle", filename);
}
// ─────────────────────────────────────────────────────────────────────
// Test 7 (M8 C2-SECRET-1): CountSecrets includes site data-connection
// PrimaryConfiguration and BackupConfiguration fields in the banner total.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void CountSecrets_includes_data_connection_primary_and_backup_configurations()
{
// One data connection with both fields populated — expect 2 secrets counted.
var dcBoth = new DataConnection("PlcA", "OpcUa", 10) { Id = 1,
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://plc-a:4840\"}", BackupConfiguration = "{\"endpoint\":\"opc.tcp://plc-a-backup:4840\"}" };
var resolved = new ResolvedExport(
TemplateFolders: Array.Empty<TemplateFolder>(),
Templates: Array.Empty<Template>(),
SharedScripts: Array.Empty<SharedScript>(),
ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
NotificationLists: Array.Empty<NotificationList>(),
SmtpConfigs: Array.Empty<SmtpConfiguration>(),
ApiMethods: Array.Empty<ApiMethod>(),
ContentManifest: Array.Empty<ManifestContentEntry>())
{
DataConnections = new List<DataConnection> { dcBoth },
};
Assert.Equal(2, TransportExportPage.CountSecrets(resolved));
}
[Fact]
public void CountSecrets_counts_only_non_empty_data_connection_configurations()
{
// Primary only — backup is null.
var dcPrimaryOnly = new DataConnection("PlcB", "OpcUa", 10) { Id = 2,
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://plc-b:4840\"}", BackupConfiguration = null };
// Neither field set — should not contribute.
var dcEmpty = new DataConnection("PlcC", "OpcUa", 10) { Id = 3,
PrimaryConfiguration = null, BackupConfiguration = null };
var resolved = new ResolvedExport(
TemplateFolders: Array.Empty<TemplateFolder>(),
Templates: Array.Empty<Template>(),
SharedScripts: Array.Empty<SharedScript>(),
ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
NotificationLists: Array.Empty<NotificationList>(),
SmtpConfigs: Array.Empty<SmtpConfiguration>(),
ApiMethods: Array.Empty<ApiMethod>(),
ContentManifest: Array.Empty<ManifestContentEntry>())
{
DataConnections = new List<DataConnection> { dcPrimaryOnly, dcEmpty },
};
Assert.Equal(1, TransportExportPage.CountSecrets(resolved));
}
[Fact]
public void CountSecrets_returns_zero_when_no_data_connections()
{
var resolved = new ResolvedExport(
TemplateFolders: Array.Empty<TemplateFolder>(),
Templates: Array.Empty<Template>(),
SharedScripts: Array.Empty<SharedScript>(),
ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
NotificationLists: Array.Empty<NotificationList>(),
SmtpConfigs: Array.Empty<SmtpConfiguration>(),
ApiMethods: Array.Empty<ApiMethod>(),
ContentManifest: Array.Empty<ManifestContentEntry>());
Assert.Equal(0, TransportExportPage.CountSecrets(resolved));
}
}
@@ -9,6 +9,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
@@ -37,6 +39,10 @@ public class TransportImportPageTests : BunitContext
{
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
// M8 E2: the Map step injects ISiteRepository to populate target-site +
// target-connection dropdowns. Register a substitute so every test (not just
// the Map tests) can render the wizard without a missing-service failure.
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
public TransportImportPageTests()
{
@@ -44,6 +50,12 @@ public class TransportImportPageTests : BunitContext
Services.AddSingleton(_importer);
Services.AddSingleton(_auditService);
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Site>());
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<DataConnection>());
Services.AddSingleton(_siteRepo);
Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
@@ -248,7 +260,8 @@ public class TransportImportPageTests : BunitContext
session.SessionId,
Arg.Any<IReadOnlyList<ImportResolution>>(),
"alice",
Arg.Any<CancellationToken>())
Arg.Any<CancellationToken>(),
Arg.Any<BundleNameMap>())
.Returns(expectedResult);
var cut = Render<TransportImportPage>();
@@ -275,7 +288,8 @@ public class TransportImportPageTests : BunitContext
rs.Any(r => r.EntityType == "Template" && r.Name == "Pump"
&& r.Action == ResolutionAction.Overwrite)),
"alice",
Arg.Any<CancellationToken>());
Arg.Any<CancellationToken>(),
Arg.Any<BundleNameMap>());
Assert.Equal(
TransportImportPage.ImportWizardStep.Result,
@@ -329,6 +343,351 @@ public class TransportImportPageTests : BunitContext
Assert.Equal(ResolutionAction.Skip, map[("Reference", "D")].Action);
}
// ─────────────────────────────────────────────────────────────────────
// Test 8 (M8 E2): the Map sub-section renders one row per required site /
// connection mapping with the auto-match defaults pre-selected, and the
// section is hidden entirely when the preview carries no required mappings.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Map_section_renders_required_rows_with_automatch_defaults()
{
// Target environment has one site (site-b) with one connection (opc-main),
// which the bundle's source site "site-a" / connection "opc-a" auto-match to.
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Site> { new("Site B", "site-b") { Id = 7 } });
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List<ImportPreviewItem>(),
new List<RequiredSiteMapping> { new("site-a", "Site A", "site-b") },
new List<RequiredConnectionMapping> { new("site-a", "opc-a", "opc-main") });
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
// Section present with one site row and one connection row.
Assert.NotNull(cut.Find("[data-testid='map-section']"));
Assert.Single(cut.FindAll("[data-testid='map-site-row']"));
Assert.Single(cut.FindAll("[data-testid='map-conn-row']"));
// Auto-match defaults: site-a → site-b, opc-a → opc-main.
var siteSelect = cut.Find("[data-testid='map-site-select-site-a']");
var selectedSiteOpt = siteSelect.QuerySelectorAll("option").Single(o => o.HasAttribute("selected"));
Assert.Equal("site-b", selectedSiteOpt.GetAttribute("value"));
var connSelect = cut.Find("[data-testid='map-conn-select-site-a-opc-a']");
var selectedConnOpt = connSelect.QuerySelectorAll("option").Single(o => o.HasAttribute("selected"));
Assert.Equal("opc-main", selectedConnOpt.GetAttribute("value"));
}
[Fact]
public async Task Map_section_hidden_when_no_required_mappings()
{
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
});
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
Assert.Empty(cut.FindAll("[data-testid='map-section']"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 9 (M8 E2): the operator's Map choices fold into the BundleNameMap
// passed to ApplyAsync — a chosen target → MapToExisting, "Create new" →
// CreateNew. Captured via the substituted IBundleImporter.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Apply_passes_BundleNameMap_built_from_map_choices()
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Site> { new("Site B", "site-b") { Id = 7 } });
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List<ImportPreviewItem>(),
// site-a auto-matches site-b; site-c has no auto-match (→ Create new default).
new List<RequiredSiteMapping>
{
new("site-a", "Site A", "site-b"),
new("site-c", "Site C", null),
},
// opc-a (under site-a) auto-matches opc-main.
new List<RequiredConnectionMapping> { new("site-a", "opc-a", "opc-main") });
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var expectedResult = new ImportResult(
Guid.NewGuid(), 0, 0, 0, 0, Array.Empty<int>(), Guid.NewGuid().ToString());
_importer.ApplyAsync(
session.SessionId,
Arg.Any<IReadOnlyList<ImportResolution>>(),
"alice",
Arg.Any<CancellationToken>(),
Arg.Any<BundleNameMap>())
.Returns(expectedResult);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster"));
await cut.InvokeAsync(async () => await InvokeAsyncMethod(cut.Instance, "ApplyAsync"));
await _importer.Received(1).ApplyAsync(
session.SessionId,
Arg.Any<IReadOnlyList<ImportResolution>>(),
"alice",
Arg.Any<CancellationToken>(),
Arg.Is<BundleNameMap>(m =>
m.Sites.Count == 2
&& m.Sites.Any(s => s.SourceSiteIdentifier == "site-a"
&& s.Action == MappingAction.MapToExisting && s.TargetSiteIdentifier == "site-b")
&& m.Sites.Any(s => s.SourceSiteIdentifier == "site-c"
&& s.Action == MappingAction.CreateNew && s.TargetSiteIdentifier == null)
&& m.Connections.Count == 1
&& m.Connections.Any(c => c.SourceSiteIdentifier == "site-a"
&& c.SourceConnectionName == "opc-a"
&& c.Action == MappingAction.MapToExisting && c.TargetConnectionName == "opc-main")));
}
// ─────────────────────────────────────────────────────────────────────
// Test 10 (M8 E2): a Modified row whose FieldDiffJson carries a code
// lineDiff renders +/- lines via LineDiffView.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Modified_row_with_code_lineDiff_renders_add_and_remove_lines()
{
const string fieldDiffJson = """
{
"changes": [
{
"field": "Code",
"oldValue": "a\nb\nc",
"newValue": "a\nB\nc",
"lineDiff": {
"hunks": [
{ "op": "context", "text": "a", "oldLineNo": 1, "newLineNo": 1 },
{ "op": "remove", "text": "b", "oldLineNo": 2 },
{ "op": "add", "text": "B", "newLineNo": 2 },
{ "op": "context", "text": "c", "oldLineNo": 3, "newLineNo": 3 }
],
"truncated": false,
"addedCount": 1,
"removedCount": 1
}
}
]
}
""";
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
{
new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
});
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
// The code line-diff block is present and shows one add + one remove line.
Assert.NotNull(cut.Find("[data-testid='code-line-diff']"));
Assert.NotNull(cut.Find("[data-testid='line-diff']"));
Assert.Single(cut.FindAll("[data-testid='line-diff-add']"));
Assert.Single(cut.FindAll("[data-testid='line-diff-remove']"));
// The raw JSON <pre> fallback is NOT used for code-field diffs.
Assert.DoesNotContain("\"lineDiff\"", cut.Find("[data-testid='code-line-diff']").InnerHtml);
// No truncation marker for a complete diff.
Assert.Empty(cut.FindAll("[data-testid='line-diff-truncated']"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 11 (M8 E2): truncation marker shows when the lineDiff is truncated.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Modified_row_with_truncated_lineDiff_shows_truncation_marker()
{
const string fieldDiffJson = """
{
"changes": [
{
"field": "Code",
"oldValue": "x",
"newValue": "y",
"lineDiff": {
"hunks": [
{ "op": "remove", "text": "x", "oldLineNo": 1 },
{ "op": "add", "text": "y", "newLineNo": 1 }
],
"truncated": true,
"addedCount": 12,
"removedCount": 8
}
}
]
}
""";
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
{
new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
});
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
cut.Render();
var marker = cut.Find("[data-testid='line-diff-truncated']");
Assert.Contains("truncated", marker.TextContent);
Assert.Contains("+12", marker.TextContent);
Assert.Contains("-8", marker.TextContent);
}
// ─────────────────────────────────────────────────────────────────────
// Test 12 (M8 E2 Fix 1): changing a source site's dropdown to a target
// that lacks the previously auto-matched connection name resets that
// connection choice to CreateNew (""), and BuildNameMap therefore emits
// a CreateNew entry — not a MapToExisting for an absent connection.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task OnSiteChoiceChanged_resets_stale_connection_choices_when_new_target_lacks_connection()
{
// Target environment: site-b has connection "opc-main"; site-c has NO connections.
var siteB = new Site("Site B", "site-b") { Id = 7 };
var siteC = new Site("Site C", "site-c") { Id = 8 };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Site> { siteB, siteC });
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
_siteRepo.GetDataConnectionsBySiteIdAsync(8, Arg.Any<CancellationToken>())
.Returns(Array.Empty<DataConnection>());
// Bundle: source site "site-a" auto-matches site-b; connection "opc-a" auto-matches "opc-main".
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List<ImportPreviewItem>(),
new List<RequiredSiteMapping> { new("site-a", "Site A", "site-b") },
new List<RequiredConnectionMapping> { new("site-a", "opc-a", "opc-main") });
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
// Drive LoadPreviewAndAdvanceAsync to seed auto-match state (site-a → site-b, opc-a → opc-main).
await cut.InvokeAsync(async () =>
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
// Verify the auto-match seed: connection choice is "opc-main".
var connChoicesBefore = GetField<Dictionary<(string, string), string>>(
cut.Instance, "_connectionChoices");
Assert.Equal("opc-main", connChoicesBefore[("site-a", "opc-a")]);
// Operator changes site-a's target from site-b → site-c (which has no connections).
await cut.InvokeAsync(async () =>
{
var method = cut.Instance.GetType().GetMethod(
"OnSiteChoiceChangedAsync",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
await (Task)method.Invoke(cut.Instance, new object[] { "site-a", "site-c" })!;
});
// Connection choice for (site-a, opc-a) must now be CreateNew ("").
var connChoicesAfter = GetField<Dictionary<(string, string), string>>(
cut.Instance, "_connectionChoices");
Assert.Equal(string.Empty, connChoicesAfter[("site-a", "opc-a")]);
// BuildNameMap must NOT emit MapToExisting for "opc-a" — it should emit CreateNew.
var nameMap = TransportImportPageTests.InvokeBuildNameMap(cut.Instance);
var connMapping = nameMap.Connections.Single(c => c.SourceConnectionName == "opc-a");
Assert.Equal(MappingAction.CreateNew, connMapping.Action);
Assert.Null(connMapping.TargetConnectionName);
}
// ─────────────────────────────────────────────────────────────────────
// Test 13 (M8 E2 Fix 2): BackToUpload clears session/preview/Map state
// so a subsequent re-upload flow cannot inherit stale data.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task BackToUpload_clears_session_and_preview_state()
{
// Seed the wizard at the Diff step with non-trivial session + preview + Map state.
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var preview = new ImportPreview(
session.SessionId,
new List<ImportPreviewItem>
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
},
new List<RequiredSiteMapping> { new("site-a", "Site A", null) },
new List<RequiredConnectionMapping> { new("site-a", "conn-x", null) });
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() =>
{
SetField(cut.Instance, "_session", session);
SetField(cut.Instance, "_preview", preview);
SetField(cut.Instance, "_resolutions",
new Dictionary<(string, string), ImportResolution>
{
[("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null),
});
SetField(cut.Instance, "_siteChoices",
new Dictionary<string, string> { ["site-a"] = "site-b" });
SetField(cut.Instance, "_connectionChoices",
new Dictionary<(string, string), string> { [("site-a", "conn-x")] = "conn-y" });
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Diff);
});
// Invoke BackToUpload.
await cut.InvokeAsync(() =>
{
var method = cut.Instance.GetType().GetMethod(
"BackToUpload",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
method.Invoke(cut.Instance, Array.Empty<object?>());
});
// Step must be back to Upload.
Assert.Equal(
TransportImportPage.ImportWizardStep.Upload,
GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
// Session, preview, resolutions, and Map state must all be cleared.
Assert.Null(GetField<object?>(cut.Instance, "_session"));
Assert.Null(GetField<object?>(cut.Instance, "_preview"));
Assert.Null(GetField<object?>(cut.Instance, "_resolutions"));
var siteChoices = GetField<Dictionary<string, string>>(cut.Instance, "_siteChoices");
Assert.Empty(siteChoices);
var connChoices = GetField<Dictionary<(string, string), string>>(cut.Instance, "_connectionChoices");
Assert.Empty(connChoices);
}
// ─────────────────────────────────────────────────────────────────────
// Reflection helpers — the wizard's per-instance state is private (the
// razor partial pattern). We poke at it via reflection rather than
@@ -369,6 +728,20 @@ public class TransportImportPageTests : BunitContext
await task;
}
/// <summary>
/// Invokes the private <c>BuildNameMap()</c> method on the page instance and
/// returns the resulting <see cref="BundleNameMap"/>. Used by Test 12 to assert
/// the map shape after a site-choice change without going through ApplyAsync.
/// </summary>
private static BundleNameMap InvokeBuildNameMap(TransportImportPage instance)
{
var method = instance.GetType().GetMethod(
"BuildNameMap",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
?? throw new InvalidOperationException("Method 'BuildNameMap' not found.");
return (BundleNameMap)method.Invoke(instance, Array.Empty<object?>())!;
}
/// <summary>
/// Seeds the wizard at Step 2 (Passphrase) with a staged bundle file — the
/// shape after an encrypted-bundle upload completed Step 1's peek and
@@ -222,6 +222,8 @@ public sealed class QueryStringDrillInTests
var importer = Substitute.For<IBundleImporter>();
Services.AddSingleton(importer);
Services.AddSingleton(Substitute.For<IAuditService>());
// M8 E2: TransportImport's Map step injects ISiteRepository.
Services.AddSingleton(Substitute.For<ISiteRepository>());
Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
@@ -0,0 +1,127 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport;
/// <summary>
/// M8 A1: focused shape / value-equality / round-trip tests for the Transport (#24)
/// name-mapping DTOs — <see cref="SiteMapping"/>, <see cref="ConnectionMapping"/>,
/// and <see cref="BundleNameMap"/>. These records cross the Central UI ⇆ ManagementActor
/// boundary (and may be persisted in an import session) via System.Text.Json, so a
/// positional/tuple slip would silently mis-map sites/connections during import.
/// Uses plain <see cref="JsonSerializer"/> (no dependency on the Transport project's
/// BundleJsonOptions) per the A1 task contract.
/// </summary>
public sealed class BundleNameMapTests
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
[Fact]
public void Empty_HasNoSitesOrConnections()
{
var map = BundleNameMap.Empty;
Assert.Empty(map.Sites);
Assert.Empty(map.Connections);
}
[Fact]
public void Empty_IsStableInstance()
{
// The Empty singleton must be reusable without aliasing surprises.
Assert.Same(BundleNameMap.Empty, BundleNameMap.Empty);
}
[Fact]
public void SiteMapping_ValueEquality_HoldsForIdenticalFields()
{
var a = new SiteMapping("site-east", MappingAction.MapToExisting, "site-1");
var b = new SiteMapping("site-east", MappingAction.MapToExisting, "site-1");
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void SiteMapping_ValueEquality_DiffersWhenActionDiffers()
{
var mapTo = new SiteMapping("site-east", MappingAction.MapToExisting, "site-1");
var createNew = new SiteMapping("site-east", MappingAction.CreateNew, null);
Assert.NotEqual(mapTo, createNew);
}
[Fact]
public void ConnectionMapping_ValueEquality_HoldsForIdenticalFields()
{
var a = new ConnectionMapping("site-east", "OPC-Main", MappingAction.MapToExisting, "OPC-Primary");
var b = new ConnectionMapping("site-east", "OPC-Main", MappingAction.MapToExisting, "OPC-Primary");
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void MapToExisting_CarriesNonNullTarget()
{
var site = new SiteMapping("site-east", MappingAction.MapToExisting, "site-1");
var conn = new ConnectionMapping("site-east", "OPC-Main", MappingAction.MapToExisting, "OPC-Primary");
Assert.Equal(MappingAction.MapToExisting, site.Action);
Assert.NotNull(site.TargetSiteIdentifier);
Assert.Equal("site-1", site.TargetSiteIdentifier);
Assert.Equal(MappingAction.MapToExisting, conn.Action);
Assert.NotNull(conn.TargetConnectionName);
Assert.Equal("OPC-Primary", conn.TargetConnectionName);
}
[Fact]
public void CreateNew_LeavesTargetNull()
{
var site = new SiteMapping("site-west", MappingAction.CreateNew, null);
var conn = new ConnectionMapping("site-west", "OPC-Aux", MappingAction.CreateNew, null);
Assert.Null(site.TargetSiteIdentifier);
Assert.Null(conn.TargetConnectionName);
}
[Fact]
public void BundleNameMap_JsonRoundTrip_PreservesSitesAndConnections()
{
var map = new BundleNameMap(
Sites: new[]
{
new SiteMapping("site-east", MappingAction.MapToExisting, "site-1"),
new SiteMapping("site-west", MappingAction.CreateNew, null),
},
Connections: new[]
{
new ConnectionMapping("site-east", "OPC-Main", MappingAction.MapToExisting, "OPC-Primary"),
new ConnectionMapping("site-west", "OPC-Aux", MappingAction.CreateNew, null),
});
var json = JsonSerializer.Serialize(map, JsonOpts);
var rt = JsonSerializer.Deserialize<BundleNameMap>(json, JsonOpts);
Assert.NotNull(rt);
// BundleNameMap's record Equals is reference-based over its IReadOnlyList members,
// and JSON deserialization yields List<T> rather than the original T[] — so the
// collections never reference-equal. The records *within* the lists have proper
// value-equality, so compare element-wise (SequenceEqual uses SiteMapping/
// ConnectionMapping value-equality).
Assert.True(map.Sites.SequenceEqual(rt!.Sites));
Assert.True(map.Connections.SequenceEqual(rt.Connections));
}
[Fact]
public void BundleNameMap_Empty_JsonRoundTrip_PreservesEmptiness()
{
var json = JsonSerializer.Serialize(BundleNameMap.Empty, JsonOpts);
var rt = JsonSerializer.Deserialize<BundleNameMap>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Empty(rt!.Sites);
Assert.Empty(rt.Connections);
}
}
@@ -1386,6 +1386,15 @@ public class ManagementActorTests : TestKit, IDisposable
.Returns(new List<Template>());
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Scripts.SharedScript>());
// M8 (B4): export now also fans out to instances (template repo) and sites
// (site repo) for the site/instance-scoped selection.
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>());
_services.AddScoped(_ => siteRepo);
var externalRepo = Substitute.For<IExternalSystemRepository>();
externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
@@ -1494,6 +1503,95 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("DoesNotExist", response.Error);
}
[Fact]
public void ExportBundleCommand_WithSiteAndInstanceNames_ResolvesToIds()
{
// M8 (B4): --sites accepts SiteIdentifier (preferred) or Name; --instances
// accepts UniqueName. The handler resolves both to surrogate ids and passes
// them on the ExportSelection.
var (exporter, _) = AddBundleSubstitutes();
var siteRepo = (ISiteRepository)_services.BuildServiceProvider()
.GetRequiredService<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>
{
new("North Plant", "NORTH-01") { Id = 11 },
new("East Plant", "EAST-02") { Id = 22 },
});
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("NORTH-01.Pump1") { Id = 101, SiteId = 11, TemplateId = 1 },
new("NORTH-01.Pump2") { Id = 102, SiteId = 11, TemplateId = 1 },
});
Commons.Types.Transport.ExportSelection? captured = null;
exporter.ExportAsync(
Arg.Do<Commons.Types.Transport.ExportSelection>(s => captured = s),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 1, 2, 3 })));
var cmd = new ExportBundleCommand(
All: false,
TemplateNames: null, SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env",
// mix the SiteIdentifier and the friendly Name to prove the dual-key match.
SiteNames: new[] { "NORTH-01", "East Plant" },
InstanceNames: new[] { "NORTH-01.Pump1" });
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(captured);
Assert.Equal(new[] { 11, 22 }, captured!.SiteIds.OrderBy(x => x));
Assert.Equal(new[] { 101 }, captured.InstanceIds);
}
[Fact]
public void ExportBundleCommand_WithAll_IncludesEverySiteAndInstance()
{
// M8 (B4): All=true ignores per-type name lists and includes every site +
// instance, mirroring how All already includes every template/etc.
var (exporter, _) = AddBundleSubstitutes();
var siteRepo = (ISiteRepository)_services.BuildServiceProvider()
.GetRequiredService<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>
{
new("North Plant", "NORTH-01") { Id = 11 },
new("East Plant", "EAST-02") { Id = 22 },
});
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("NORTH-01.Pump1") { Id = 101, SiteId = 11, TemplateId = 1 },
new("EAST-02.Pump9") { Id = 909, SiteId = 22, TemplateId = 1 },
});
Commons.Types.Transport.ExportSelection? captured = null;
exporter.ExportAsync(
Arg.Do<Commons.Types.Transport.ExportSelection>(s => captured = s),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 1 })));
var actor = CreateActor();
actor.Tell(Envelope(AllExportCommand(), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(captured);
Assert.Equal(new[] { 11, 22 }, captured!.SiteIds.OrderBy(x => x));
Assert.Equal(new[] { 101, 909 }, captured.InstanceIds.OrderBy(x => x));
}
[Fact]
public void ImportBundleCommand_WithBlockerRow_AbortsBeforeApply()
{
@@ -1579,12 +1677,16 @@ public class ManagementActorTests : TestKit, IDisposable
}));
IReadOnlyList<Commons.Types.Transport.ImportResolution>? captured = null;
// M8 (D3): the handler now calls the 5-arg ApplyAsync overload (trailing
// nameMap:), so the arrange must include the BundleNameMap arg matcher or
// NSubstitute treats it as an unmatched call and never runs the capture.
importer.ApplyAsync(
Arg.Any<Guid>(),
Arg.Do<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(
r => captured = r),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
Arg.Any<CancellationToken>(),
Arg.Any<Commons.Types.Transport.BundleNameMap?>())
.Returns(new Commons.Types.Transport.ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
@@ -1611,6 +1713,285 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Equal(Commons.Types.Transport.ResolutionAction.Skip, dupResolutions[0].Action);
}
// ------------------------------------------------------------------------
// M8 (D3): import name-map plumbing — PreviewBundleResult carries the
// required mappings; HandleImportBundle merges explicit specs + auto-match +
// create-missing flags into the BundleNameMap passed to ApplyAsync.
// ------------------------------------------------------------------------
/// <summary>
/// Configures the substituted importer with a Load + Preview returning the
/// supplied required mappings (and no conflicting artifact rows), and a
/// capturing ApplyAsync. Returns the session id and a getter for the captured
/// <see cref="Commons.Types.Transport.BundleNameMap"/>.
/// </summary>
private static (Guid SessionId, Func<Commons.Types.Transport.BundleNameMap?> CapturedMap) ConfigureImportForMapping(
Commons.Interfaces.Transport.IBundleImporter importer,
IReadOnlyList<Commons.Types.Transport.RequiredSiteMapping> siteMappings,
IReadOnlyList<Commons.Types.Transport.RequiredConnectionMapping> connectionMappings)
{
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
Array.Empty<Commons.Types.Transport.ImportPreviewItem>(),
siteMappings,
connectionMappings));
Commons.Types.Transport.BundleNameMap? captured = null;
importer.ApplyAsync(
Arg.Any<Guid>(),
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>(),
Arg.Do<Commons.Types.Transport.BundleNameMap?>(m => captured = m))
.Returns(new Commons.Types.Transport.ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: "correlation"));
return (sessionId, () => captured);
}
[Fact]
public void ImportBundleCommand_ExplicitSpec_OverridesAutoMatch()
{
// An explicit --map-site spec (target present → MapToExisting) wins over
// the preview's auto-match suggestion.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite",
SiteMappings: new[] { new SiteMappingSpec("NORTH-01", "PLANT-A") });
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal("NORTH-01", site.SourceSiteIdentifier);
Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, site.Action);
Assert.Equal("PLANT-A", site.TargetSiteIdentifier);
}
[Fact]
public void ImportBundleCommand_NoSpec_FallsBackToAutoMatch()
{
// With no explicit spec, the preview's auto-match becomes a MapToExisting.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(payload, null, "overwrite");
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, site.Action);
Assert.Equal("AUTO-MATCH", site.TargetSiteIdentifier);
}
[Fact]
public void ImportBundleCommand_NoAutoMatchWithCreateMissing_CreatesNew()
{
// No spec, no auto-match, but --create-missing-sites → CreateNew.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: null),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite", CreateMissingSites: true);
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, site.Action);
Assert.Null(site.TargetSiteIdentifier);
}
[Fact]
public void ImportBundleCommand_ExplicitCreateNewSpec_CreatesNew()
{
// An explicit spec with a null target means CreateNew even without the
// create-missing flag.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite",
SiteMappings: new[] { new SiteMappingSpec("NORTH-01", TargetSiteIdentifier: null) });
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, site.Action);
}
[Fact]
public void ImportBundleCommand_UnresolvedWithoutCreateMissing_Fails()
{
// No spec, no auto-match, no create-missing flag → unresolved → fail
// before ApplyAsync.
var (_, importer) = AddBundleSubstitutes();
ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: null),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(payload, null, "overwrite");
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("unresolved", response.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("NORTH-01", response.Error);
// Apply must NOT have been called — the merge fails before it.
importer.DidNotReceive().ApplyAsync(
Arg.Any<Guid>(),
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>(),
Arg.Any<Commons.Types.Transport.BundleNameMap?>());
}
[Fact]
public void ImportBundleCommand_ConnectionSpecAndCreateMissing_BuildMap()
{
// Connection-side mirror: an explicit spec maps to existing, and an
// unmatched second connection is created via --create-missing-connections.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
Array.Empty<Commons.Types.Transport.RequiredSiteMapping>(),
new[]
{
new Commons.Types.Transport.RequiredConnectionMapping(
"NORTH-01", "OpcA", AutoMatchTargetName: null),
new Commons.Types.Transport.RequiredConnectionMapping(
"NORTH-01", "OpcB", AutoMatchTargetName: null),
});
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite",
ConnectionMappings: new[] { new ConnectionMappingSpec("NORTH-01", "OpcA", "OpcLive") },
CreateMissingConnections: true);
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
Assert.Equal(2, map!.Connections.Count);
var opcA = map.Connections.Single(c => c.SourceConnectionName == "OpcA");
Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, opcA.Action);
Assert.Equal("OpcLive", opcA.TargetConnectionName);
var opcB = map.Connections.Single(c => c.SourceConnectionName == "OpcB");
Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, opcB.Action);
Assert.Null(opcB.TargetConnectionName);
}
[Fact]
public void PreviewBundleCommand_SurfacesRequiredMappings()
{
// HandlePreviewBundle must echo the preview's required mappings on the
// PreviewBundleResult so the CLI/UI can show what needs resolving.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
Array.Empty<Commons.Types.Transport.ImportPreviewItem>(),
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO"),
},
new[]
{
new Commons.Types.Transport.RequiredConnectionMapping(
"NORTH-01", "OpcA", AutoMatchTargetName: null),
}));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
actor.Tell(Envelope(new PreviewBundleCommand(payload, null), "Administrator"));
var success = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// The result is camelCase JSON (ManagementActor.SerializeResult). The new
// PreviewBundleResult fields must surface the preview's required mappings.
Assert.Contains("requiredSiteMappings", success.JsonData);
Assert.Contains("requiredConnectionMappings", success.JsonData);
Assert.Contains("NORTH-01", success.JsonData);
Assert.Contains("North Plant", success.JsonData);
Assert.Contains("AUTO", success.JsonData);
Assert.Contains("OpcA", success.JsonData);
}
// ========================================================================
// Native alarm source CRUD (Task 21)
// ========================================================================
@@ -41,6 +41,9 @@ public sealed class CompositionImportTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
@@ -39,6 +39,9 @@ public sealed class ConflictResolutionTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
@@ -3,8 +3,10 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -50,11 +52,15 @@ public sealed class BundleExporterTests : IDisposable
services.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseInMemoryDatabase(dbName));
// Repositories the resolver pulls from.
// Repositories the resolver pulls from. M8 (B4): the resolver now injects
// ISiteRepository to walk the site/data-connection/instance closure, so it
// must be registered or the BuildServiceProvider-time graph resolution for
// DependencyResolver fails.
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<ISiteRepository, SiteRepository>();
// Audit pipeline — AuditService writes via the EF context + reads the
// bundle-import correlation id from the scoped context (null here, since
@@ -277,4 +283,180 @@ public sealed class BundleExporterTests : IDisposable
Assert.Equal("stg", entry.EntityName);
}
}
[Fact]
public async Task ExportAsync_with_site_selection_packs_sites_dataconnections_and_instances()
{
// Arrange: a site with one data connection and one instance (on a template)
// bound to that connection. Selecting the site pulls its data connection +
// all its instances; the instance's binding edge proves the closure walks
// the instance's child collections (review item I3 — proves the aggregate
// wiring actually carries the site/instance arrays into the bundle).
int siteId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var template = new Template("PumpStation") { Description = "for instance" };
ctx.Templates.Add(template);
var site = new Site("North Plant", "NORTH-01") { Description = "north site" };
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
siteId = site.Id;
var conn = new DataConnection("PlcA", "OpcUa", site.Id);
ctx.DataConnections.Add(conn);
await ctx.SaveChangesAsync();
var instance = new Instance("NORTH-01.Pump1") { TemplateId = template.Id, SiteId = site.Id };
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
ctx.InstanceConnectionBindings.Add(new InstanceConnectionBinding("Flow")
{
InstanceId = instance.Id,
DataConnectionId = conn.Id,
});
await ctx.SaveChangesAsync();
}
// Act: select the site (no central-config entities) with deps on, so the
// site closure also pulls each instance's owning template — the realistic
// "export a whole site" path.
Stream bundleStream;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var selection = new ExportSelection(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true,
SiteIds: new[] { siteId });
bundleStream = await exporter.ExportAsync(
selection, user: "carol", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
byte[] bundleBytes;
using (var ms = new MemoryStream())
{
await bundleStream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
// Assert: manifest summary counts the site/data-connection/instance, and
// the unpacked content carries the actual arrays (I3 — without the
// EntityAggregate wiring these would be empty).
var serializer = _provider.GetRequiredService<BundleSerializer>();
BundleManifest manifest;
byte[] rawContent;
using (var ms = new MemoryStream(bundleBytes, writable: false))
{
manifest = serializer.ReadManifest(ms);
}
using (var ms = new MemoryStream(bundleBytes, writable: false))
{
rawContent = serializer.ReadContentBytes(ms, manifest);
}
Assert.Equal(1, manifest.Summary.Sites);
Assert.Equal(1, manifest.Summary.DataConnections);
Assert.Equal(1, manifest.Summary.Instances);
var content = serializer.UnpackContent(rawContent, manifest, passphrase: null, encryptor: null);
Assert.Single(content.Sites);
Assert.Equal("NORTH-01", content.Sites[0].SiteIdentifier);
Assert.Single(content.DataConnections);
Assert.Equal("PlcA", content.DataConnections[0].Name);
Assert.Single(content.Instances);
Assert.Equal("NORTH-01.Pump1", content.Instances[0].UniqueName);
Assert.Equal("PumpStation", content.Instances[0].TemplateName);
// The instance carries its binding, and the manifest dep-edge resolves the
// owning site by identifier (I1) — Site:NORTH-01, never Site:<rawId>.
Assert.Single(content.Instances[0].ConnectionBindings);
var instanceEntry = Assert.Single(
manifest.Contents,
e => e.Type == "Instance" && e.Name == "NORTH-01.Pump1");
Assert.Contains($"Site:NORTH-01", instanceEntry.DependsOn);
Assert.DoesNotContain(instanceEntry.DependsOn, d => d == $"Site:{siteId}");
}
[Fact]
public async Task ExportAsync_directly_selected_instance_records_owning_site_by_identifier()
{
// I1: a directly-selected instance with IncludeDependencies=false does NOT
// pack its owning site, yet the manifest dep-edge must still read
// Site:<identifier> (resolved via the site-identifier enrichment) rather
// than degrading to Site:<rawId>.
int instanceId;
int siteId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var template = new Template("Valve") { Description = "for instance" };
ctx.Templates.Add(template);
var site = new Site("South Plant", "SOUTH-09") { Description = "south" };
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
siteId = site.Id;
var instance = new Instance("SOUTH-09.Valve1") { TemplateId = template.Id, SiteId = site.Id };
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
instanceId = instance.Id;
}
Stream bundleStream;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var selection = new ExportSelection(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false,
InstanceIds: new[] { instanceId });
bundleStream = await exporter.ExportAsync(
selection, user: "dave", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
byte[] bundleBytes;
using (var ms = new MemoryStream())
{
await bundleStream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
var serializer = _provider.GetRequiredService<BundleSerializer>();
BundleManifest manifest;
using (var ms = new MemoryStream(bundleBytes, writable: false))
{
manifest = serializer.ReadManifest(ms);
}
// Instance is packed; its owning site is NOT (deps off, site not selected).
Assert.Equal(1, manifest.Summary.Instances);
Assert.Equal(0, manifest.Summary.Sites);
var instanceEntry = Assert.Single(
manifest.Contents,
e => e.Type == "Instance" && e.Name == "SOUTH-09.Valve1");
// I1: the dep-edge reads the portable identifier, not the raw surrogate id.
Assert.Contains("Site:SOUTH-09", instanceEntry.DependsOn);
Assert.DoesNotContain(instanceEntry.DependsOn, d => d == $"Site:{siteId}");
}
}
@@ -2,7 +2,9 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
@@ -13,6 +15,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
@@ -61,9 +65,19 @@ public sealed class BundleImporterApplyTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
// #16 (M8 D2): the stale-instance probe is implemented in DeploymentManager
// and the flatten/hash primitives in TemplateEngine — register both so
// BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can
// compute StaleInstanceIds against genuine revision hashes.
services.AddTemplateEngine();
services.AddDeploymentManager();
_provider = services.BuildServiceProvider();
}
@@ -923,4 +937,238 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Contains(preview.Items, item =>
item.EntityType == "ApiMethod" && item.Name == "GetStatus");
}
// ─────────────────────────────────────────────────────────────────────
// #16 (M8 D2): StaleInstanceIds enumeration. Overwriting a template changes
// its flattened-config hash, so deployed instances of that template drift
// from their DeployedConfigSnapshot.RevisionHash and must surface as stale
// in the import result. NotDeployed (incl. freshly-imported) instances are
// never stale.
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Seeds an Enabled instance of <paramref name="templateName"/> and a
/// <see cref="DeployedConfigSnapshot"/> whose RevisionHash is the GENUINE hash
/// the deployment flattening pipeline computes for the instance's CURRENT
/// (pre-import) config. Returns the instance id. Mirrors how the deploy path
/// captures a snapshot so the staleness comparison is faithful.
/// </summary>
private async Task<int> SeedDeployedInstanceWithRealSnapshotAsync(
string templateName, string instanceName)
{
int instanceId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var template = await ctx.Templates.SingleAsync(t => t.Name == templateName);
var instance = new Instance(instanceName)
{
TemplateId = template.Id,
SiteId = (await ctx.Sites.FirstAsync()).Id,
State = InstanceState.Enabled,
};
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
instanceId = instance.Id;
// Compute the REAL current revision hash via the same pipeline the
// deploy path uses, then persist it as the deployed snapshot so the
// import's drift comparison runs against a genuine baseline.
var pipeline = scope.ServiceProvider.GetRequiredService<IFlatteningPipeline>();
var flattened = await pipeline.FlattenAndValidateAsync(instanceId);
Assert.True(flattened.IsSuccess,
$"Seed flatten failed: {(flattened.IsFailure ? flattened.Error : "(success)")}");
ctx.DeployedConfigSnapshots.Add(new DeployedConfigSnapshot(
deploymentId: Guid.NewGuid().ToString(),
revisionHash: flattened.Value.RevisionHash,
configurationJson: System.Text.Json.JsonSerializer.Serialize(flattened.Value.Configuration))
{
InstanceId = instanceId,
DeployedAt = DateTimeOffset.UtcNow,
});
await ctx.SaveChangesAsync();
}
return instanceId;
}
private async Task<int> SeedSiteAsync()
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var site = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("Plant 1", "plant-1")
{
NodeAAddress = "akka://site@10.0.0.1:2552",
NodeBAddress = "akka://site@10.0.0.2:2552",
};
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
return site.Id;
}
[Fact]
public async Task ApplyAsync_overwriting_template_marks_deployed_instance_stale()
{
// Arrange — seed Pump with the BUNDLE's (new) attribute shape, export it,
// then mutate the target template + reseed the deployed snapshot to the
// OLD shape so an Overwrite restores the new shape and drifts the deployed
// instance off its captured hash.
await SeedSiteAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("Pump") { Description = "v2" };
t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" }); // extra attr in bundle
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Mutate the target template to the OLD shape (drop Pressure, change Flow),
// then deploy an instance capturing the OLD config's hash.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
t.Description = "v1";
var pressure = t.Attributes.Single(a => a.Name == "Pressure");
t.Attributes.Remove(pressure);
ctx.TemplateAttributes.Remove(pressure);
t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
await ctx.SaveChangesAsync();
}
var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
// Act — Overwrite Pump back to the bundle's v2 shape.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
user: "bob");
}
// Assert — the deployed instance drifted off its snapshot hash and is stale.
Assert.Equal(1, result.Overwritten);
Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
}
[Fact]
public async Task ApplyAsync_overwriting_template_does_not_mark_unchanged_template_instance_stale()
{
// Two templates: Pump (overwritten with a CHANGED shape) and Valve
// (overwritten with an IDENTICAL shape — its instance must NOT be stale).
// The export carries both at their seeded shape; we mutate only Pump in
// the target so its overwrite changes the hash, while Valve's overwrite
// is a no-op hash-wise.
await SeedSiteAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var pump = new Template("Pump") { Description = "v2" };
pump.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
pump.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" });
ctx.Templates.Add(pump);
var valve = new Template("Valve") { Description = "stable" };
valve.Attributes.Add(new TemplateAttribute("Position") { DataType = DataType.Float, Value = "0.5" });
ctx.Templates.Add(valve);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Mutate ONLY Pump in the target so its overwrite changes the hash; leave
// Valve identical to the bundle so its overwrite is hash-neutral.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var pump = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
pump.Attributes.Remove(pressure);
ctx.TemplateAttributes.Remove(pressure);
await ctx.SaveChangesAsync();
}
var pumpInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
var valveInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Valve", "Valve-Deployed");
// Act — Overwrite BOTH templates.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Overwrite, null),
new("Template", "Valve", ResolutionAction.Overwrite, null),
},
user: "bob");
}
// Assert — only Pump's deployed instance is stale; Valve's is not.
Assert.Contains(pumpInstanceId, result.StaleInstanceIds);
Assert.DoesNotContain(valveInstanceId, result.StaleInstanceIds);
}
[Fact]
public async Task ApplyAsync_freshly_imported_NotDeployed_instance_is_never_stale()
{
// A bundle that carries a template AND an instance of it: the imported
// instance enters the target NotDeployed (no snapshot), so even though the
// template is "new" to the target it must NOT appear in StaleInstanceIds.
// We also overwrite an EXISTING deployed instance's template to prove the
// stale list contains the deployed one but never the fresh one.
await SeedSiteAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("Pump") { Description = "v2" };
t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Mutate the target template, then deploy an instance capturing the OLD hash.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
await ctx.SaveChangesAsync();
}
var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
// Also seed a NotDeployed instance of the same template (simulates a
// never-deployed / freshly-imported instance) — it has no snapshot.
int notDeployedInstanceId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
var fresh = new Instance("Pump-Fresh")
{
TemplateId = t.Id,
SiteId = (await ctx.Sites.FirstAsync()).Id,
State = InstanceState.NotDeployed,
};
ctx.Instances.Add(fresh);
await ctx.SaveChangesAsync();
notDeployedInstanceId = fresh.Id;
}
// Act — Overwrite Pump.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
user: "bob");
}
// Assert — deployed instance is stale; the NotDeployed one is excluded.
Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
Assert.DoesNotContain(notDeployedInstanceId, result.StaleInstanceIds);
}
}
@@ -2,16 +2,20 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -39,6 +43,9 @@ public sealed class BundleImporterPreviewTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
@@ -75,6 +82,139 @@ public sealed class BundleImporterPreviewTests : IDisposable
return ms.ToArray();
}
/// <summary>Exports every seeded site (and its instance/connection closure) into a bundle.</summary>
private async Task<Stream> ExportAllSitesAsync()
{
await using var scope = _provider.CreateAsyncScope();
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true,
SiteIds: siteIds);
return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
/// <summary>
/// Seeds a template + a site + a site-scoped data connection + an instance bound
/// to that connection (with one attribute override) so a full site closure can be
/// exported and previewed. Returns the seeded site identifier + connection name.
/// </summary>
private async Task SeedSiteClosureAsync(
string siteIdentifier = "plant-1",
string siteName = "Plant 1",
string connectionName = "OpcUaPrimary",
string templateName = "Pump",
string instanceName = "Pump-01")
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var template = new Template(templateName) { Description = "pump tpl" };
template.Attributes.Add(new TemplateAttribute("Flow") { Value = "0" });
ctx.Templates.Add(template);
var site = new Site(siteName, siteIdentifier) { Description = "primary plant" };
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
var conn = new DataConnection(connectionName, "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
FailoverRetryCount = 3,
};
ctx.DataConnections.Add(conn);
var instance = new Instance(instanceName)
{
TemplateId = template.Id,
SiteId = site.Id,
State = InstanceState.Enabled,
};
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Flow") { OverrideValue = "42" });
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
instance.ConnectionBindings.Add(new InstanceConnectionBinding("Flow")
{
DataConnectionId = conn.Id,
DataSourceReferenceOverride = "ns=3;s=Pump.Flow",
});
await ctx.SaveChangesAsync();
}
/// <summary>
/// Packs an arbitrary <see cref="BundleContentDto"/> into a real, loadable plaintext
/// bundle so blocker scenarios can reference entities that the export resolver would
/// never carry (e.g. an instance pointing at a template absent from both bundle and
/// target). Reuses the production manifest builder + serializer for hash fidelity.
/// </summary>
private async Task<byte[]> PackBundleAsync(BundleContentDto content)
{
await using var scope = _provider.CreateAsyncScope();
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
var summary = new BundleSummary(
Templates: content.Templates.Count,
TemplateFolders: content.TemplateFolders.Count,
SharedScripts: content.SharedScripts.Count,
ExternalSystems: content.ExternalSystems.Count,
DbConnections: content.DatabaseConnections.Count,
NotificationLists: content.NotificationLists.Count,
SmtpConfigs: content.SmtpConfigs.Count,
ApiMethods: content.ApiMethods.Count,
Sites: content.Sites.Count,
DataConnections: content.DataConnections.Count,
Instances: content.Instances.Count);
var manifest = manifestBuilder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaBridgeVersion: "1.0.0",
encryption: null,
summary: summary,
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: serializer.SerializeContentBytes(content));
var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
return await StreamToBytes(packed);
}
private static BundleContentDto EmptyContent() => new(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
private static InstanceDto SimpleInstanceDto(
string uniqueName,
string templateName,
string siteIdentifier,
IReadOnlyList<InstanceConnectionBindingDto>? bindings = null) => new(
UniqueName: uniqueName,
TemplateName: templateName,
SiteIdentifier: siteIdentifier,
AreaName: null,
State: InstanceState.Enabled,
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
ConnectionBindings: bindings ?? Array.Empty<InstanceConnectionBindingDto>());
[Fact]
public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match()
{
@@ -385,4 +525,261 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.Equal(ConflictKind.Identical, valveItem.Kind);
Assert.Equal(ConflictKind.Identical, tankItem.Kind);
}
// ---- M8 C2: site/connection/instance preview + required-mapping detection + blockers ----
[Fact]
public async Task PreviewAsync_into_fresh_target_surfaces_required_mappings_with_null_auto_match()
{
// Arrange: seed a full site closure, export it, then wipe the target so the
// site + connection are absent. The preview must surface the site +
// connection as required mappings with no auto-match (create-new implied),
// and classify the site/connection/instance as New.
await SeedSiteClosureAsync();
var bundleStream = await ExportAllSitesAsync();
var bytes = await StreamToBytes(bundleStream);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Instances.RemoveRange(ctx.Instances);
ctx.DataConnections.RemoveRange(ctx.DataConnections);
ctx.Sites.RemoveRange(ctx.Sites);
ctx.Templates.RemoveRange(ctx.Templates);
await ctx.SaveChangesAsync();
}
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
var siteMapping = Assert.Single(preview.RequiredSiteMappings, m => m.SourceSiteIdentifier == "plant-1");
Assert.Null(siteMapping.AutoMatchTargetIdentifier);
Assert.Equal("Plant 1", siteMapping.SourceSiteName);
var connMapping = Assert.Single(preview.RequiredConnectionMappings,
m => m.SourceSiteIdentifier == "plant-1" && m.SourceConnectionName == "OpcUaPrimary");
Assert.Null(connMapping.AutoMatchTargetName);
// The fresh target carries none of these, so each is classified New (and the
// missing template would block — but we wiped the template too, so the
// instance also blocks; assert the New site/connection at minimum).
Assert.Contains(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1" && i.Kind == ConflictKind.New);
// C2: DataConnection preview items are site-qualified ({site}/{name}).
Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/OpcUaPrimary" && i.Kind == ConflictKind.New);
}
[Fact]
public async Task PreviewAsync_into_populated_target_auto_matches_site_and_connection()
{
// Arrange: seed a site closure, export it, leave the target unchanged. The
// bundle's site + connection identity-match the target, so both required
// mappings auto-match and the per-type diffs read Identical.
await SeedSiteClosureAsync();
var bundleStream = await ExportAllSitesAsync();
var bytes = await StreamToBytes(bundleStream);
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
var siteMapping = Assert.Single(preview.RequiredSiteMappings, m => m.SourceSiteIdentifier == "plant-1");
Assert.Equal("plant-1", siteMapping.AutoMatchTargetIdentifier);
var connMapping = Assert.Single(preview.RequiredConnectionMappings,
m => m.SourceSiteIdentifier == "plant-1" && m.SourceConnectionName == "OpcUaPrimary");
Assert.Equal("OpcUaPrimary", connMapping.AutoMatchTargetName);
// The site + connection match the target exactly → Identical, not New.
var siteItem = Assert.Single(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1");
Assert.Equal(ConflictKind.Identical, siteItem.Kind);
// C2: DataConnection preview items are site-qualified ({site}/{name}).
var connItem = Assert.Single(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/OpcUaPrimary");
Assert.Equal(ConflictKind.Identical, connItem.Kind);
// No blocker — the template + connection both resolve in the target.
Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance");
}
[Fact]
public async Task PreviewAsync_two_sites_same_connection_name_emit_two_distinct_site_qualified_items()
{
// C2: a bundle with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must surface
// TWO distinct DataConnection preview items, each site-qualified
// ({site}/{name}) — NOT a single collapsed "OpcUaPrimary" item. This is what
// lets the operator (and the apply path) resolve each site's connection
// independently. Hand-pack the bundle so both same-named connections are
// present without a heavy two-site export.
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>())
{
Sites = new[]
{
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
new SiteDto("plant-2", "Plant 2", null, null, null, null, null),
},
DataConnections = new[]
{
new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 3, null),
new DataConnectionDto("plant-2", "OpcUaPrimary", "OpcUa", 3, null),
},
Instances = Array.Empty<InstanceDto>(),
};
var bytes = await PackBundleAsync(content);
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
var connItems = preview.Items
.Where(i => i.EntityType == "DataConnection")
.Select(i => i.Name)
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
// Two distinct site-qualified items — NOT one bare "OpcUaPrimary".
Assert.Equal(new[] { "plant-1/OpcUaPrimary", "plant-2/OpcUaPrimary" }, connItems);
Assert.DoesNotContain(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary");
}
[Fact]
public async Task PreviewAsync_modified_instance_against_hydrated_target_shows_child_diff_not_all_added()
{
// Arrange: seed a site closure (instance has a Flow=42 attribute override),
// export it, then mutate the TARGET instance's override value. The diff must
// surface a single modified child override — proving CompareInstance received
// a HYDRATED existing instance (I2). A non-hydrated entity would read the
// existing children as empty and report the bundle's override as an addition.
await SeedSiteClosureAsync();
var bundleStream = await ExportAllSitesAsync();
var bytes = await StreamToBytes(bundleStream);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var ovr = await ctx.InstanceAttributeOverrides.SingleAsync(o => o.AttributeName == "Flow");
ovr.OverrideValue = "99"; // bundle still carries 42
await ctx.SaveChangesAsync();
}
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
var instItem = Assert.Single(preview.Items, i => i.EntityType == "Instance" && i.Name == "Pump-01");
Assert.Equal(ConflictKind.Modified, instItem.Kind);
Assert.NotNull(instItem.FieldDiffJson);
// The diff names the AttributeOverrides collection (a change, NOT an add of
// every existing child). The hydrated existing instance already had a Flow
// override, so the diff is a modification, not an addition.
Assert.Contains("AttributeOverrides", instItem.FieldDiffJson!, StringComparison.Ordinal);
// A non-hydrated existing would have reported AreaName/State unchanged but
// every override as Added — assert the diff did NOT explode into the other
// unchanged children (ConnectionBindings DataSourceReference matches verbatim).
Assert.DoesNotContain("\"ConnectionBindings\"", instItem.FieldDiffJson!, StringComparison.Ordinal);
}
[Fact]
public async Task PreviewAsync_instance_with_missing_template_emits_blocker()
{
// Arrange: hand-build a bundle whose instance references a template present in
// NEITHER the bundle nor the target. (The real exporter would never produce
// this — it always carries the instance's template — so we pack directly.)
var content = EmptyContent() with
{
Instances = new[]
{
SimpleInstanceDto("Ghost-01", templateName: "NoSuchTemplate", siteIdentifier: "plant-1"),
},
};
var bytes = await PackBundleAsync(content);
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
Assert.Contains(preview.Items, i =>
i.Kind == ConflictKind.Blocker
&& i.EntityType == "Instance"
&& i.Name == "Ghost-01"
&& i.BlockerReason is not null
&& i.BlockerReason.Contains("NoSuchTemplate", StringComparison.Ordinal));
}
[Fact]
public async Task PreviewAsync_referenced_connection_absent_from_bundle_and_target_emits_blocker()
{
// Arrange: seed the template in the target so the instance's TEMPLATE resolves
// (isolating the connection blocker), then hand-build a bundle whose instance
// binds an attribute to a connection that is in neither the bundle nor the
// target.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "resolves" });
await ctx.SaveChangesAsync();
}
var content = EmptyContent() with
{
Instances = new[]
{
SimpleInstanceDto("Pump-99", templateName: "Pump", siteIdentifier: "plant-1",
bindings: new[]
{
new InstanceConnectionBindingDto("Flow", "PhantomConn", DataSourceReferenceOverride: null),
}),
},
};
var bytes = await PackBundleAsync(content);
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
// The unresolved connection blocks… (C2: blocker Name is site-qualified
// {site}/{name} so two sites' same-named missing connections don't collide).
Assert.Contains(preview.Items, i =>
i.Kind == ConflictKind.Blocker
&& i.Name == "plant-1/PhantomConn"
&& i.BlockerReason is not null
&& i.BlockerReason.Contains("PhantomConn", StringComparison.Ordinal));
// …but the template resolves in the target, so the instance is NOT a
// missing-template blocker.
Assert.DoesNotContain(preview.Items, i =>
i.Kind == ConflictKind.Blocker
&& i.EntityType == "Instance"
&& i.Name == "Pump-99");
}
}
@@ -84,6 +84,9 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
@@ -53,6 +53,9 @@ public sealed class RoundTripTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
@@ -51,6 +51,9 @@ public sealed class SemanticValidatorImportTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
@@ -41,6 +41,9 @@ public sealed class ValidationFailureTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
@@ -1,7 +1,9 @@
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
@@ -15,8 +17,9 @@ public sealed class DependencyResolverTests
private readonly IExternalSystemRepository _externalSystems = Substitute.For<IExternalSystemRepository>();
private readonly INotificationRepository _notifications = Substitute.For<INotificationRepository>();
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
private DependencyResolver Sut() => new(_templates, _externalSystems, _notifications, _inboundApi);
private DependencyResolver Sut() => new(_templates, _externalSystems, _notifications, _inboundApi, _sites);
private static ExportSelection SelectTemplates(params int[] ids) => new(
TemplateIds: ids,
@@ -32,12 +35,35 @@ public sealed class DependencyResolverTests
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: ids,
IncludeDependencies: true);
private static ExportSelection SelectSites(int[] siteIds, bool includeDeps = true) => new(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: includeDeps,
SiteIds: siteIds);
private static ExportSelection SelectInstances(int[] instanceIds, bool includeDeps = true, int[]? siteIds = null) => new(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: includeDeps,
SiteIds: siteIds ?? Array.Empty<int>(),
InstanceIds: instanceIds);
private void StubTemplate(Template t)
{
_templates.GetTemplateWithChildrenAsync(t.Id, Arg.Any<CancellationToken>()).Returns(t);
@@ -64,6 +90,36 @@ public sealed class DependencyResolverTests
_templates.GetAllFoldersAsync(Arg.Any<CancellationToken>()).Returns(folders);
}
private void StubSite(Site site)
{
_sites.GetSiteByIdAsync(site.Id, Arg.Any<CancellationToken>()).Returns(site);
}
private void StubSiteConnections(int siteId, params DataConnection[] connections)
{
_sites.GetDataConnectionsBySiteIdAsync(siteId, Arg.Any<CancellationToken>()).Returns(connections);
}
private void StubSiteInstances(int siteId, params Instance[] instances)
{
_sites.GetInstancesBySiteIdAsync(siteId, Arg.Any<CancellationToken>()).Returns(instances);
}
// Stubs the per-instance child getters on the template repo so the resolver can
// load an instance + its (possibly empty) override/binding collections by id.
private void StubInstance(Instance instance)
{
_templates.GetInstanceByIdAsync(instance.Id, Arg.Any<CancellationToken>()).Returns(instance);
_templates.GetOverridesByInstanceIdAsync(instance.Id, Arg.Any<CancellationToken>())
.Returns(instance.AttributeOverrides.ToList());
_templates.GetAlarmOverridesByInstanceIdAsync(instance.Id, Arg.Any<CancellationToken>())
.Returns(instance.AlarmOverrides.ToList());
_templates.GetNativeAlarmSourceOverridesByInstanceIdAsync(instance.Id, Arg.Any<CancellationToken>())
.Returns(instance.NativeAlarmSourceOverrides.ToList());
_templates.GetBindingsByInstanceIdAsync(instance.Id, Arg.Any<CancellationToken>())
.Returns(instance.ConnectionBindings.ToList());
}
[Fact]
public async Task Resolve_includes_base_template_for_composed_template()
{
@@ -212,4 +268,200 @@ public sealed class DependencyResolverTests
Assert.Equal("Middle", result.Templates[1].Name);
Assert.Equal("Top", result.Templates[2].Name);
}
// ---------------------------------------------------------------------------
// M8 (B1): site/instance-scoped expansion.
// ---------------------------------------------------------------------------
[Fact]
public async Task Resolve_site_pulls_its_connections_and_instances()
{
var site = new Site("Plant North", "site-n") { Id = 1 };
var conn = new DataConnection("opc-1", "OpcUa", site.Id) { Id = 5 };
var template = new Template("PumpTpl") { Id = 9 };
var instance = new Instance("Pump01") { Id = 30, SiteId = site.Id, TemplateId = template.Id };
StubSite(site);
StubSiteConnections(site.Id, conn);
StubSiteInstances(site.Id, instance);
StubInstance(instance);
StubTemplate(template);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectSites(new[] { site.Id }), CancellationToken.None);
Assert.Single(result.Sites, s => s.SiteIdentifier == "site-n");
Assert.Single(result.DataConnections, c => c.Name == "opc-1");
Assert.Single(result.Instances, i => i.UniqueName == "Pump01");
// IncludeDependencies feeds the instance's template into the template closure.
Assert.Single(result.Templates, t => t.Id == 9);
}
[Fact]
public async Task Resolve_instance_with_deps_pulls_site_connections_and_template()
{
var site = new Site("Plant South", "site-s") { Id = 2 };
var boundConn = new DataConnection("opc-bound", "OpcUa", site.Id) { Id = 11 };
var alarmConn = new DataConnection("opc-alarm", "OpcUa", site.Id) { Id = 12 };
var unusedConn = new DataConnection("opc-unused", "OpcUa", site.Id) { Id = 13 };
var template = new Template("TankTpl") { Id = 40 };
var instance = new Instance("Tank01") { Id = 60, SiteId = site.Id, TemplateId = template.Id };
instance.ConnectionBindings.Add(new InstanceConnectionBinding("level") { InstanceId = 60, DataConnectionId = boundConn.Id });
instance.NativeAlarmSourceOverrides.Add(
new InstanceNativeAlarmSourceOverride("Tank.HiHi") { InstanceId = 60, ConnectionNameOverride = "opc-alarm" });
StubInstance(instance);
StubSite(site);
// The instance's site connections are loaded during dependency expansion.
StubSiteConnections(site.Id, boundConn, alarmConn, unusedConn);
StubTemplate(template);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectInstances(new[] { instance.Id }), CancellationToken.None);
// Owning site pulled in.
Assert.Single(result.Sites, s => s.SiteIdentifier == "site-s");
// Template fed into the closure.
Assert.Single(result.Templates, t => t.Id == 40);
// Only the connections the instance references travel — bound (by id) + alarm (by name).
Assert.Contains(result.DataConnections, c => c.Name == "opc-bound");
Assert.Contains(result.DataConnections, c => c.Name == "opc-alarm");
Assert.DoesNotContain(result.DataConnections, c => c.Name == "opc-unused");
// The instance carries its overrides/bindings on its navigation collections.
var resolvedInstance = Assert.Single(result.Instances);
Assert.Single(resolvedInstance.ConnectionBindings);
Assert.Single(resolvedInstance.NativeAlarmSourceOverrides);
}
[Fact]
public async Task Resolve_instance_without_deps_does_not_pull_site_or_template()
{
var site = new Site("Plant West", "site-w") { Id = 3 };
var template = new Template("ValveTpl") { Id = 41 };
var instance = new Instance("Valve01") { Id = 70, SiteId = site.Id, TemplateId = template.Id };
StubInstance(instance);
StubSite(site);
StubSiteConnections(site.Id);
StubTemplate(template);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(
SelectInstances(new[] { instance.Id }, includeDeps: false),
CancellationToken.None);
// The instance is present, but with deps off neither its site nor its template are pulled.
Assert.Single(result.Instances, i => i.UniqueName == "Valve01");
Assert.Empty(result.Sites);
Assert.Empty(result.Templates);
}
[Fact]
public async Task Resolve_dedups_instance_when_site_and_instance_both_selected()
{
var site = new Site("Plant East", "site-e") { Id = 4 };
var template = new Template("MixerTpl") { Id = 42 };
var instance = new Instance("Mixer01") { Id = 80, SiteId = site.Id, TemplateId = template.Id };
StubSite(site);
StubSiteConnections(site.Id);
StubSiteInstances(site.Id, instance);
StubInstance(instance);
StubTemplate(template);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
// Select the site AND the instance it owns — the instance must appear once.
var result = await Sut().ResolveAsync(
SelectInstances(new[] { instance.Id }, includeDeps: true, siteIds: new[] { site.Id }),
CancellationToken.None);
Assert.Single(result.Instances, i => i.UniqueName == "Mixer01");
Assert.Single(result.Sites, s => s.SiteIdentifier == "site-e");
// The instance was loaded exactly once (site path), not re-loaded via the instance path.
await _templates.Received(1).GetInstanceByIdAsync(instance.Id, Arg.Any<CancellationToken>());
}
[Fact]
public async Task Resolve_orders_sites_connections_and_instances_deterministically()
{
// Two sites, out of identifier order; each with two connections out of name order;
// instances out of unique-name order. Result must be sorted.
var siteB = new Site("Beta", "site-b") { Id = 1 };
var siteA = new Site("Alpha", "site-a") { Id = 2 };
var connB2 = new DataConnection("zeta", "OpcUa", siteB.Id) { Id = 10 };
var connB1 = new DataConnection("alpha", "OpcUa", siteB.Id) { Id = 11 };
var connA1 = new DataConnection("mike", "OpcUa", siteA.Id) { Id = 12 };
var instZ = new Instance("Zulu") { Id = 20, SiteId = siteA.Id, TemplateId = 0 };
var instA = new Instance("Alpha") { Id = 21, SiteId = siteB.Id, TemplateId = 0 };
StubSite(siteB);
StubSite(siteA);
StubSiteConnections(siteB.Id, connB2, connB1);
StubSiteConnections(siteA.Id, connA1);
StubSiteInstances(siteB.Id, instA);
StubSiteInstances(siteA.Id, instZ);
StubInstance(instZ);
StubInstance(instA);
// TemplateId 0 → no template; GetTemplateWithChildrenAsync(0) returns null by default.
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectSites(new[] { siteB.Id, siteA.Id }), CancellationToken.None);
// Sites by SiteIdentifier.
Assert.Equal(new[] { "site-a", "site-b" }, result.Sites.Select(s => s.SiteIdentifier).ToArray());
// Connections by (SiteIdentifier, Name): site-a/mike, site-b/alpha, site-b/zeta.
Assert.Equal(
new[] { "mike", "alpha", "zeta" },
result.DataConnections.Select(c => c.Name).ToArray());
// Instances by UniqueName.
Assert.Equal(new[] { "Alpha", "Zulu" }, result.Instances.Select(i => i.UniqueName).ToArray());
}
[Fact]
public async Task Resolve_emits_manifest_entries_with_dependency_edges_for_site_scope()
{
var site = new Site("Plant North", "site-n") { Id = 1 };
var conn = new DataConnection("opc-1", "OpcUa", site.Id) { Id = 5 };
var template = new Template("PumpTpl") { Id = 9 };
var instance = new Instance("Pump01") { Id = 30, SiteId = site.Id, TemplateId = template.Id };
instance.ConnectionBindings.Add(new InstanceConnectionBinding("flow") { InstanceId = 30, DataConnectionId = conn.Id });
StubSite(site);
StubSiteConnections(site.Id, conn);
StubSiteInstances(site.Id, instance);
StubInstance(instance);
StubTemplate(template);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectSites(new[] { site.Id }), CancellationToken.None);
var siteEntry = Assert.Single(result.ContentManifest, e => e.Type == "Site");
Assert.Equal("site-n", siteEntry.Name);
Assert.Empty(siteEntry.DependsOn);
var connEntry = Assert.Single(result.ContentManifest, e => e.Type == "DataConnection");
Assert.Equal("site-n/opc-1", connEntry.Name);
Assert.Contains("Site:site-n", connEntry.DependsOn);
var instEntry = Assert.Single(result.ContentManifest, e => e.Type == "Instance");
Assert.Equal("Pump01", instEntry.Name);
Assert.Contains("Template:PumpTpl", instEntry.DependsOn);
Assert.Contains("Site:site-n", instEntry.DependsOn);
Assert.Contains("DataConnection:site-n/opc-1", instEntry.DependsOn);
}
}
@@ -0,0 +1,390 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
/// <summary>
/// Tests for <see cref="ArtifactDiff"/>: the structured per-line Myers code diff
/// embedded in <c>FieldDiffJson</c> (T20) and the M8 Site / DataConnection /
/// Instance compares. The diff records are private, so assertions parse the
/// serialized <see cref="ImportPreviewItem.FieldDiffJson"/>.
/// </summary>
public sealed class ArtifactDiffTests
{
private readonly ArtifactDiff _diff = new();
// ---- JSON inspection helpers ----
private static JsonElement Changes(ImportPreviewItem item)
{
Assert.NotNull(item.FieldDiffJson);
using var doc = JsonDocument.Parse(item.FieldDiffJson!);
// Clone so the element survives the using-scope disposal.
return doc.RootElement.GetProperty("changes").Clone();
}
private static JsonElement ChangeFor(ImportPreviewItem item, string field)
{
foreach (var c in Changes(item).EnumerateArray())
{
if (c.GetProperty("field").GetString() == field)
{
return c.Clone();
}
}
Assert.Fail($"No change for field '{field}'. JSON: {item.FieldDiffJson}");
return default; // unreachable
}
private static IReadOnlyList<(string Op, string Text)> Hunks(JsonElement change)
{
var lineDiff = change.GetProperty("lineDiff");
var hunks = new List<(string, string)>();
foreach (var h in lineDiff.GetProperty("hunks").EnumerateArray())
{
hunks.Add((h.GetProperty("op").GetString()!, h.GetProperty("text").GetString()!));
}
return hunks;
}
// ============ T20: structured per-line code diff ============
[Fact]
public void SharedScript_ModifiedCode_EmitsStructuredLineDiff()
{
var existing = new SharedScript("calc", "var a = 1;\nvar b = 2;\nreturn a + b;");
var incoming = new SharedScriptDto(
Name: "calc",
Code: "var a = 1;\nvar b = 20;\nreturn a + b;",
ParameterDefinitions: existing.ParameterDefinitions,
ReturnDefinition: existing.ReturnDefinition);
var item = _diff.CompareSharedScript(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Code");
// Compact summary retained for fallback.
Assert.Equal("<3 lines>", change.GetProperty("oldValue").GetString());
Assert.Equal("<3 lines>", change.GetProperty("newValue").GetString());
var lineDiff = change.GetProperty("lineDiff");
Assert.False(lineDiff.GetProperty("truncated").GetBoolean());
Assert.Equal(1, lineDiff.GetProperty("addedCount").GetInt32());
Assert.Equal(1, lineDiff.GetProperty("removedCount").GetInt32());
var hunks = Hunks(change);
// The changed middle line shows as a remove + add; unchanged lines are context.
Assert.Contains(("remove", "var b = 2;"), hunks);
Assert.Contains(("add", "var b = 20;"), hunks);
Assert.Contains(("context", "var a = 1;"), hunks);
Assert.Contains(("context", "return a + b;"), hunks);
// Not a "<N lines>"-only marker any more.
Assert.DoesNotContain(hunks, h => h.Text.Contains("lines>"));
}
[Fact]
public void SharedScript_IdenticalCode_NoChange()
{
var existing = new SharedScript("calc", "return 42;");
var incoming = new SharedScriptDto("calc", "return 42;", existing.ParameterDefinitions, existing.ReturnDefinition);
var item = _diff.CompareSharedScript(incoming, existing);
Assert.Equal(ConflictKind.Identical, item.Kind);
Assert.Null(item.FieldDiffJson);
}
[Fact]
public void ApiMethod_ModifiedScript_EmitsStructuredLineDiff()
{
var existing = new ApiMethod("doThing", "log(\"a\");") { TimeoutSeconds = 30 };
var incoming = new ApiMethodDto(
Name: "doThing",
Script: "log(\"a\");\nlog(\"b\");",
ParameterDefinitions: existing.ParameterDefinitions,
ReturnDefinition: existing.ReturnDefinition,
TimeoutSeconds: 30);
var item = _diff.CompareApiMethod(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Script");
var lineDiff = change.GetProperty("lineDiff");
Assert.Equal(1, lineDiff.GetProperty("addedCount").GetInt32());
Assert.Equal(0, lineDiff.GetProperty("removedCount").GetInt32());
var hunks = Hunks(change);
Assert.Contains(("context", "log(\"a\");"), hunks);
Assert.Contains(("add", "log(\"b\");"), hunks);
}
[Fact]
public void TemplateScript_ModifiedCode_EmitsStructuredLineDiff()
{
var existing = MakeTemplate("T1");
existing.Scripts.Add(new TemplateScript("onScan", "old1\nold2"));
var incoming = MakeTemplateDto("T1", scripts:
[
new TemplateScriptDto("onScan", "old1\nnew2", null, null, null, null, false, null),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Scripts.onScan");
var hunks = Hunks(change);
Assert.Contains(("context", "old1"), hunks);
Assert.Contains(("remove", "old2"), hunks);
Assert.Contains(("add", "new2"), hunks);
}
// ============ M8: CompareSite ============
[Fact]
public void CompareSite_ExistingNull_New()
{
var dto = new SiteDto("site-a", "Site A", null, null, null, null, null);
var item = _diff.CompareSite(dto, existing: null);
Assert.Equal(ConflictKind.New, item.Kind);
Assert.Equal("Site", item.EntityType);
Assert.Equal("site-a", item.Name);
Assert.Null(item.FieldDiffJson);
}
[Fact]
public void CompareSite_FieldDiffers_Modified()
{
var existing = new Site("Site A", "site-a") { Description = "old", NodeAAddress = "10.0.0.1" };
var dto = new SiteDto("site-a", "Site A", "new", "10.0.0.1", null, null, null);
var item = _diff.CompareSite(dto, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Description");
Assert.Equal("old", change.GetProperty("oldValue").GetString());
Assert.Equal("new", change.GetProperty("newValue").GetString());
}
[Fact]
public void CompareSite_NoChanges_Identical()
{
var existing = new Site("Site A", "site-a")
{
Description = "d",
NodeAAddress = "a",
NodeBAddress = "b",
GrpcNodeAAddress = "ga",
GrpcNodeBAddress = "gb",
};
var dto = new SiteDto("site-a", "Site A", "d", "a", "b", "ga", "gb");
var item = _diff.CompareSite(dto, existing);
Assert.Equal(ConflictKind.Identical, item.Kind);
Assert.Null(item.FieldDiffJson);
}
// ============ M8: CompareDataConnection ============
[Fact]
public void CompareDataConnection_ExistingNull_New()
{
var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3, null);
var item = _diff.CompareDataConnection(dto, existing: null);
Assert.Equal(ConflictKind.New, item.Kind);
Assert.Equal("DataConnection", item.EntityType);
Assert.Equal("opc1", item.Name);
}
[Fact]
public void CompareDataConnection_ProtocolDiffers_Modified()
{
var existing = new DataConnection("opc1", "OpcUa", siteId: 1) { FailoverRetryCount = 3 };
var dto = new DataConnectionDto("site-a", "opc1", "MxAccess", 3, null);
var item = _diff.CompareDataConnection(dto, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Protocol");
Assert.Equal("OpcUa", change.GetProperty("oldValue").GetString());
Assert.Equal("MxAccess", change.GetProperty("newValue").GetString());
}
[Fact]
public void CompareDataConnection_SecretValueChangesButPresencePreserved_NoSecretEcho()
{
// Both sides HAVE a primary configuration secret; only the value differs.
// Presence-only comparison => no Secrets.* change, and the value is never echoed.
var existing = new DataConnection("opc1", "OpcUa", siteId: 1)
{
FailoverRetryCount = 3,
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://OLD:4840\"}",
};
var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3,
new SecretsBlock(new Dictionary<string, string>
{
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://NEW:4840\"}",
}));
var item = _diff.CompareDataConnection(dto, existing);
Assert.Equal(ConflictKind.Identical, item.Kind);
Assert.Null(item.FieldDiffJson);
// The secret value must never appear anywhere in the diff output.
Assert.DoesNotContain("4840", item.FieldDiffJson ?? string.Empty);
}
[Fact]
public void CompareDataConnection_SecretPresenceFlips_ShowsPresentMarkerOnly()
{
// Existing has a primary config; incoming carries no secret => presence change.
var existing = new DataConnection("opc1", "OpcUa", siteId: 1)
{
FailoverRetryCount = 3,
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://h:4840\"}",
};
var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3, Secrets: null);
var item = _diff.CompareDataConnection(dto, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Secrets.PrimaryConfiguration");
Assert.Equal("<present>", change.GetProperty("oldValue").GetString());
// newValue is null and omitted from JSON by the WhenWritingNull policy.
Assert.False(change.TryGetProperty("newValue", out _));
Assert.DoesNotContain("4840", item.FieldDiffJson!);
}
// ============ M8: CompareInstance ============
[Fact]
public void CompareInstance_ExistingNull_New()
{
var dto = MakeInstanceDto("inst-1");
var item = _diff.CompareInstance(dto, existing: null);
Assert.Equal(ConflictKind.New, item.Kind);
Assert.Equal("Instance", item.EntityType);
Assert.Equal("inst-1", item.Name);
}
[Fact]
public void CompareInstance_StateDiffers_Modified()
{
var existing = new Instance("inst-1") { State = InstanceState.Disabled };
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled);
var item = _diff.CompareInstance(dto, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "State");
Assert.Equal("Disabled", change.GetProperty("oldValue").GetString());
Assert.Equal("Enabled", change.GetProperty("newValue").GetString());
}
[Fact]
public void CompareInstance_NoChanges_Identical()
{
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled);
var item = _diff.CompareInstance(dto, existing);
Assert.Equal(ConflictKind.Identical, item.Kind);
Assert.Null(item.FieldDiffJson);
}
[Fact]
public void CompareInstance_TemplateAndSiteNamesDiffer_Modified()
{
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
var dto = MakeInstanceDto("inst-1", template: "TmplB", site: "site-b", state: InstanceState.Enabled);
var item = _diff.CompareInstance(dto, existing,
existingTemplateName: "TmplA", existingSiteIdentifier: "site-a");
Assert.Equal(ConflictKind.Modified, item.Kind);
Assert.Equal("TmplA", ChangeFor(item, "TemplateName").GetProperty("oldValue").GetString());
Assert.Equal("TmplB", ChangeFor(item, "TemplateName").GetProperty("newValue").GetString());
Assert.Equal("site-a", ChangeFor(item, "SiteIdentifier").GetProperty("oldValue").GetString());
Assert.Equal("site-b", ChangeFor(item, "SiteIdentifier").GetProperty("newValue").GetString());
}
[Fact]
public void CompareInstance_ChildOverridesAddedRemovedModified()
{
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
// Will be REMOVED (present existing, absent incoming).
existing.AttributeOverrides.Add(new InstanceAttributeOverride("Setpoint") { OverrideValue = "10" });
// Will be MODIFIED (present both, value differs).
existing.AttributeOverrides.Add(new InstanceAttributeOverride("Scale") { OverrideValue = "1.0" });
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled, attributeOverrides:
[
new InstanceAttributeOverrideDto("Scale", "2.0", null), // modified
new InstanceAttributeOverrideDto("Offset", "5", null), // added
]);
var item = _diff.CompareInstance(dto, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var removed = ChangeFor(item, "AttributeOverrides.Setpoint");
Assert.Equal("<present>", removed.GetProperty("oldValue").GetString());
Assert.False(removed.TryGetProperty("newValue", out _));
var added = ChangeFor(item, "AttributeOverrides.Offset");
Assert.False(added.TryGetProperty("oldValue", out _));
Assert.Equal("<added>", added.GetProperty("newValue").GetString());
var modified = ChangeFor(item, "AttributeOverrides.Scale");
Assert.Equal("<modified>", modified.GetProperty("oldValue").GetString());
Assert.Equal("<modified>", modified.GetProperty("newValue").GetString());
}
// ---- builders ----
private static Commons.Entities.Templates.Template MakeTemplate(string name) =>
new(name);
private static TemplateDto MakeTemplateDto(string name, IReadOnlyList<TemplateScriptDto>? scripts = null) =>
new(
Name: name,
FolderName: null,
BaseTemplateName: null,
Description: null,
Attributes: Array.Empty<TemplateAttributeDto>(),
Alarms: Array.Empty<TemplateAlarmDto>(),
Scripts: scripts ?? Array.Empty<TemplateScriptDto>(),
Compositions: Array.Empty<TemplateCompositionDto>());
private static InstanceDto MakeInstanceDto(
string uniqueName,
string template = "Tmpl",
string site = "site-a",
InstanceState state = InstanceState.Enabled,
IReadOnlyList<InstanceAttributeOverrideDto>? attributeOverrides = null) =>
new(
UniqueName: uniqueName,
TemplateName: template,
SiteIdentifier: site,
AreaName: null,
State: state,
AttributeOverrides: attributeOverrides ?? Array.Empty<InstanceAttributeOverrideDto>(),
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
ConnectionBindings: Array.Empty<InstanceConnectionBindingDto>());
}
@@ -110,6 +110,7 @@ public sealed class BundleImporterLoadTests
externalRepo: Substitute.For<IExternalSystemRepository>(),
notificationRepo: Substitute.For<INotificationRepository>(),
inboundApiRepo: Substitute.For<IInboundApiRepository>(),
siteRepo: Substitute.For<ISiteRepository>(),
auditService: Substitute.For<IAuditService>(),
correlationContext: Substitute.For<IAuditCorrelationContext>(),
// LoadAsync never touches the DbContext — Preview/Apply do. Build
@@ -0,0 +1,192 @@
using ZB.MOM.WW.ScadaBridge.Transport.Import;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
public sealed class LineDifferTests
{
[Fact]
public void IdenticalText_NoAddsOrRemoves_NotTruncated()
{
var result = LineDiffer.Diff("a\nb\nc", "a\nb\nc");
Assert.Equal(0, result.AddedCount);
Assert.Equal(0, result.RemovedCount);
Assert.False(result.Truncated);
// All emitted lines (if any) must be Context.
Assert.All(result.Lines, l => Assert.Equal(LineDiffOp.Context, l.Op));
}
[Fact]
public void OldEmpty_NewThreeLines_ThreeAdds_NewLineNos1To3()
{
var result = LineDiffer.Diff("", "x\ny\nz");
var adds = result.Lines.Where(l => l.Op == LineDiffOp.Add).ToList();
Assert.Equal(3, result.AddedCount);
Assert.Equal(0, result.RemovedCount);
Assert.False(result.Truncated);
Assert.Equal(3, adds.Count);
Assert.Equal(new[] { 1, 2, 3 }, adds.Select(a => a.NewLineNo!.Value).ToArray());
Assert.Equal(new[] { "x", "y", "z" }, adds.Select(a => a.Text).ToArray());
// Add lines have no old line number.
Assert.All(adds, a => Assert.Null(a.OldLineNo));
}
[Fact]
public void NewEmpty_OldTwoLines_TwoRemoves_OldLineNos1To2()
{
var result = LineDiffer.Diff("p\nq", "");
var removes = result.Lines.Where(l => l.Op == LineDiffOp.Remove).ToList();
Assert.Equal(0, result.AddedCount);
Assert.Equal(2, result.RemovedCount);
Assert.False(result.Truncated);
Assert.Equal(2, removes.Count);
Assert.Equal(new[] { 1, 2 }, removes.Select(r => r.OldLineNo!.Value).ToArray());
Assert.Equal(new[] { "p", "q" }, removes.Select(r => r.Text).ToArray());
// Remove lines have no new line number.
Assert.All(removes, r => Assert.Null(r.NewLineNo));
}
[Fact]
public void SingleMiddleLineChange_OneRemoveOneAdd_ContextPreserved()
{
var result = LineDiffer.Diff("a\nB\nc", "a\nX\nc");
var removes = result.Lines.Where(l => l.Op == LineDiffOp.Remove).ToList();
var adds = result.Lines.Where(l => l.Op == LineDiffOp.Add).ToList();
var contexts = result.Lines.Where(l => l.Op == LineDiffOp.Context).ToList();
Assert.Equal(1, result.RemovedCount);
Assert.Equal(1, result.AddedCount);
Assert.False(result.Truncated);
var remove = Assert.Single(removes);
Assert.Equal("B", remove.Text);
Assert.Equal(2, remove.OldLineNo);
Assert.Null(remove.NewLineNo);
var add = Assert.Single(adds);
Assert.Equal("X", add.Text);
Assert.Equal(2, add.NewLineNo);
Assert.Null(add.OldLineNo);
// Context lines "a" and "c" carry both line numbers.
Assert.Equal(2, contexts.Count);
var ctxA = contexts.Single(c => c.Text == "a");
Assert.Equal(1, ctxA.OldLineNo);
Assert.Equal(1, ctxA.NewLineNo);
var ctxC = contexts.Single(c => c.Text == "c");
Assert.Equal(3, ctxC.OldLineNo);
Assert.Equal(3, ctxC.NewLineNo);
}
[Fact]
public void CrlfNormalization_NoChanges()
{
var result = LineDiffer.Diff("a\r\nb", "a\nb");
Assert.Equal(0, result.AddedCount);
Assert.Equal(0, result.RemovedCount);
Assert.False(result.Truncated);
}
[Fact]
public void LoneCarriageReturnNormalization_NoChanges()
{
var result = LineDiffer.Diff("a\rb", "a\nb");
Assert.Equal(0, result.AddedCount);
Assert.Equal(0, result.RemovedCount);
Assert.False(result.Truncated);
}
[Fact]
public void NullOld_NewSingleLine_OneAdd()
{
var result = LineDiffer.Diff(null, "x");
Assert.Equal(1, result.AddedCount);
Assert.Equal(0, result.RemovedCount);
var add = Assert.Single(result.Lines, l => l.Op == LineDiffOp.Add);
Assert.Equal("x", add.Text);
Assert.Equal(1, add.NewLineNo);
}
[Fact]
public void NullNew_OldSingleLine_OneRemove()
{
var result = LineDiffer.Diff("x", null);
Assert.Equal(0, result.AddedCount);
Assert.Equal(1, result.RemovedCount);
var remove = Assert.Single(result.Lines, l => l.Op == LineDiffOp.Remove);
Assert.Equal("x", remove.Text);
Assert.Equal(1, remove.OldLineNo);
}
[Fact]
public void BothNull_NoChanges()
{
var result = LineDiffer.Diff(null, null);
Assert.Equal(0, result.AddedCount);
Assert.Equal(0, result.RemovedCount);
Assert.False(result.Truncated);
}
[Fact]
public void SizeCap_OldEmpty_ThousandNewLines_TruncatedToMax()
{
var newText = string.Join("\n", Enumerable.Range(1, 1000).Select(i => $"line{i}"));
var result = LineDiffer.Diff("", newText, maxLines: 400);
Assert.True(result.Lines.Count <= 400);
Assert.True(result.Truncated);
Assert.Equal(1000, result.AddedCount);
Assert.Equal(0, result.RemovedCount);
}
[Fact]
public void InterleavedCase_LcsContextPreserved()
{
// old: 1 2 3 4 new: 1 3 4 5 => remove 2, context 1/3/4, add 5
var result = LineDiffer.Diff("1\n2\n3\n4", "1\n3\n4\n5");
Assert.Equal(1, result.AddedCount);
Assert.Equal(1, result.RemovedCount);
Assert.False(result.Truncated);
var remove = Assert.Single(result.Lines, l => l.Op == LineDiffOp.Remove);
Assert.Equal("2", remove.Text);
Assert.Equal(2, remove.OldLineNo);
var add = Assert.Single(result.Lines, l => l.Op == LineDiffOp.Add);
Assert.Equal("5", add.Text);
Assert.Equal(4, add.NewLineNo);
var contextTexts = result.Lines
.Where(l => l.Op == LineDiffOp.Context)
.Select(l => l.Text)
.ToList();
Assert.Equal(new[] { "1", "3", "4" }, contextTexts);
// Verify ordering: the remove of "2" appears after context "1" and before context "3".
var ops = result.Lines.Select(l => (l.Op, l.Text)).ToList();
var idxCtx1 = ops.FindIndex(x => x is { Op: LineDiffOp.Context, Text: "1" });
var idxRem2 = ops.FindIndex(x => x is { Op: LineDiffOp.Remove, Text: "2" });
var idxCtx3 = ops.FindIndex(x => x is { Op: LineDiffOp.Context, Text: "3" });
Assert.True(idxCtx1 < idxRem2 && idxRem2 < idxCtx3);
}
[Fact]
public void TrailingNewline_ProducesTrailingEmptyLine()
{
// "a\n" splits to ["a", ""] — both texts identical => no changes.
var result = LineDiffer.Diff("a\n", "a\n");
Assert.Equal(0, result.AddedCount);
Assert.Equal(0, result.RemovedCount);
}
}
@@ -0,0 +1,203 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization;
/// <summary>
/// Round-trip + forward-compat coverage for the M8 site/instance-scoped bundle
/// DTOs (<see cref="SiteDto"/>, <see cref="DataConnectionDto"/>,
/// <see cref="InstanceDto"/> and the extended <see cref="BundleContentDto"/>).
/// </summary>
public sealed class BundleDtoSerializationTests
{
// Use the production serialization contract (WhenWritingNull + enum-as-string
// + unknown-member tolerance) so these tests exercise the real wire format.
private static readonly JsonSerializerOptions Options = BundleJsonOptions.Default;
private static BundleContentDto NewFullContent() =>
new(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>())
{
Sites = new[]
{
new SiteDto(
SiteIdentifier: "site-a",
Name: "Site A",
Description: "Primary site",
NodeAAddress: "akka.tcp://site@10.0.0.1:7000",
NodeBAddress: "akka.tcp://site@10.0.0.2:7000",
GrpcNodeAAddress: "10.0.0.1:8083",
GrpcNodeBAddress: "10.0.0.2:8083"),
},
DataConnections = new[]
{
new DataConnectionDto(
SiteIdentifier: "site-a",
Name: "opc-main",
Protocol: "OpcUa",
FailoverRetryCount: 5,
Secrets: new SecretsBlock(new Dictionary<string, string>
{
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://primary:4840\"}",
["BackupConfiguration"] = "{\"endpoint\":\"opc.tcp://backup:4840\"}",
})),
},
Instances = new[]
{
new InstanceDto(
UniqueName: "Pump-001",
TemplateName: "PumpTemplate",
SiteIdentifier: "site-a",
AreaName: "AreaWest",
State: InstanceState.Enabled,
AttributeOverrides: new[]
{
new InstanceAttributeOverrideDto(
AttributeName: "Setpoint",
OverrideValue: "42.5",
ElementDataType: DataType.Double),
},
AlarmOverrides: new[]
{
new InstanceAlarmOverrideDto(
AlarmCanonicalName: "HighTemp",
TriggerConfigurationOverride: "{\"limit\":90}",
PriorityLevelOverride: 800),
},
NativeAlarmSourceOverrides: new[]
{
new InstanceNativeAlarmSourceOverrideDto(
SourceCanonicalName: "AnC.Source1",
ConnectionNameOverride: "opc-main",
SourceReferenceOverride: "ns=2;s=Alarm1",
ConditionFilterOverride: "severity>=500"),
},
ConnectionBindings: new[]
{
new InstanceConnectionBindingDto(
AttributeName: "Pressure",
ConnectionName: "opc-main",
DataSourceReferenceOverride: "ns=2;s=Pressure"),
}),
},
};
[Fact]
public void FullContent_RoundTrips_WithDeepEquality()
{
var original = NewFullContent();
var json = JsonSerializer.Serialize(original, Options);
var roundTripped = JsonSerializer.Deserialize<BundleContentDto>(json, Options);
Assert.NotNull(roundTripped);
// Record value-equality compares collection *references*, not contents, so
// assert the structural members explicitly to get real deep equality.
// Assert.Equal over IEnumerable compares element-by-element (each element
// is itself a record, hence value-compared).
Assert.Equal(original.Sites, roundTripped!.Sites);
Assert.Equal(original.DataConnections.Count, roundTripped.DataConnections.Count);
Assert.Equal(original.Instances.Count, roundTripped.Instances.Count);
var site = Assert.Single(roundTripped.Sites);
Assert.Equal(original.Sites[0], site);
var conn = Assert.Single(roundTripped.DataConnections);
Assert.Equal("site-a", conn.SiteIdentifier);
Assert.Equal("opc-main", conn.Name);
Assert.Equal("OpcUa", conn.Protocol);
Assert.Equal(5, conn.FailoverRetryCount);
Assert.NotNull(conn.Secrets);
Assert.Equal(
"{\"endpoint\":\"opc.tcp://primary:4840\"}",
conn.Secrets!.Values["PrimaryConfiguration"]);
Assert.Equal(
"{\"endpoint\":\"opc.tcp://backup:4840\"}",
conn.Secrets.Values["BackupConfiguration"]);
var inst = Assert.Single(roundTripped.Instances);
Assert.Equal("Pump-001", inst.UniqueName);
Assert.Equal("PumpTemplate", inst.TemplateName);
Assert.Equal("site-a", inst.SiteIdentifier);
Assert.Equal("AreaWest", inst.AreaName);
Assert.Equal(InstanceState.Enabled, inst.State);
Assert.Equal(original.Instances[0].AttributeOverrides[0], inst.AttributeOverrides[0]);
Assert.Equal(original.Instances[0].AlarmOverrides[0], inst.AlarmOverrides[0]);
Assert.Equal(original.Instances[0].NativeAlarmSourceOverrides[0], inst.NativeAlarmSourceOverrides[0]);
Assert.Equal(original.Instances[0].ConnectionBindings[0], inst.ConnectionBindings[0]);
}
[Fact]
public void EnumsSerializeAsStrings_ForHumanReadableWire()
{
var json = JsonSerializer.Serialize(NewFullContent(), Options);
// InstanceState + DataType go out as their names, not integers, because
// the shared options register JsonStringEnumConverter.
Assert.Contains("\"Enabled\"", json, StringComparison.Ordinal);
Assert.Contains("\"Double\"", json, StringComparison.Ordinal);
}
[Fact]
public void LegacyContent_MissingNewArrays_DeserializesToEmpty_NotNull()
{
// A pre-M8 bundle content blob: only the original central-config arrays
// are present; sites/dataConnections/instances fields are entirely absent
// from the JSON text.
const string legacyJson = """
{
"TemplateFolders": [],
"Templates": [],
"SharedScripts": [],
"ExternalSystems": [],
"DatabaseConnections": [],
"NotificationLists": [],
"SmtpConfigs": [],
"ApiMethods": []
}
""";
var content = JsonSerializer.Deserialize<BundleContentDto>(legacyJson, Options);
Assert.NotNull(content);
// The forward-compat invariant: the new arrays come back EMPTY, never null.
Assert.NotNull(content!.Sites);
Assert.Empty(content.Sites);
Assert.NotNull(content.DataConnections);
Assert.Empty(content.DataConnections);
Assert.NotNull(content.Instances);
Assert.Empty(content.Instances);
}
[Fact]
public void DefaultConstructed_NewArrays_AreEmpty_NotNull()
{
// Existing positional callers that never set the M8 members still get
// empty (not null) collections, so consumers can enumerate unconditionally.
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
Assert.NotNull(content.Sites);
Assert.Empty(content.Sites);
Assert.NotNull(content.DataConnections);
Assert.Empty(content.DataConnections);
Assert.NotNull(content.Instances);
Assert.Empty(content.Instances);
}
}
@@ -1,7 +1,9 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
@@ -406,4 +408,214 @@ public sealed class EntitySerializerTests
Assert.Equal(DataType.Double, rtAttr.DataType);
Assert.Null(rtAttr.ElementDataType);
}
// --- M8 (B2): site / data-connection / instance mapping ------------------
/// <summary>
/// Builds a populated aggregate: one template (so the instance's TemplateName
/// resolves), one site, one OPC UA data connection on that site, and one
/// instance with one of each child collection (attribute/alarm/native-alarm-
/// source override + a connection binding pointing at the data connection).
/// </summary>
private static EntityAggregate MakePopulatedSiteAggregate()
{
var template = new Template("Pump") { Id = 7 };
var site = new Site("North Plant", "north")
{
Id = 3,
Description = "primary",
NodeAAddress = "akka://sys@10.0.0.1:4053",
NodeBAddress = "akka://sys@10.0.0.2:4053",
GrpcNodeAAddress = "https://10.0.0.1:8083",
GrpcNodeBAddress = "https://10.0.0.2:8083",
};
var conn = new DataConnection("PlcLink", "OpcUa", siteId: 3)
{
Id = 11,
FailoverRetryCount = 5,
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://plc1:4840\",\"password\":\"s3cret\"}",
BackupConfiguration = "{\"endpoint\":\"opc.tcp://plc2:4840\"}",
};
var inst = new Instance("Pump-001")
{
Id = 21,
TemplateId = 7,
SiteId = 3,
State = InstanceState.Enabled,
};
inst.AttributeOverrides.Add(new InstanceAttributeOverride("Setpoint")
{
InstanceId = 21,
OverrideValue = "55",
});
inst.AlarmOverrides.Add(new InstanceAlarmOverride("HighPressure")
{
InstanceId = 21,
TriggerConfigurationOverride = "{\"hi\":90}",
PriorityLevelOverride = 1,
});
inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("PlcAlarms")
{
InstanceId = 21,
SourceReferenceOverride = "ns=2;s=Pump1",
});
inst.ConnectionBindings.Add(new InstanceConnectionBinding("Pressure")
{
InstanceId = 21,
DataConnectionId = 11,
DataSourceReferenceOverride = "ns=2;s=Pump1.P",
});
return MakeEmptyAggregate() with
{
Templates = new[] { template },
Sites = new[] { site },
DataConnections = new[] { conn },
Instances = new[] { inst },
};
}
[Fact]
public void ToDto_maps_site_connection_instance_with_secrets_and_name_links()
{
var aggregate = MakePopulatedSiteAggregate();
var dto = new EntitySerializer().ToBundleContent(aggregate);
// Site: straight field copy, referenced by SiteIdentifier downstream.
var dtoSite = Assert.Single(dto.Sites);
Assert.Equal("north", dtoSite.SiteIdentifier);
Assert.Equal("North Plant", dtoSite.Name);
Assert.Equal("akka://sys@10.0.0.1:4053", dtoSite.NodeAAddress);
Assert.Equal("https://10.0.0.2:8083", dtoSite.GrpcNodeBAddress);
// Data connection: owning site resolved to SiteIdentifier; the protocol
// config lands in the SecretsBlock — NOT in any public field of the DTO.
var dtoConn = Assert.Single(dto.DataConnections);
Assert.Equal("north", dtoConn.SiteIdentifier);
Assert.Equal("PlcLink", dtoConn.Name);
Assert.Equal("OpcUa", dtoConn.Protocol);
Assert.Equal(5, dtoConn.FailoverRetryCount);
Assert.NotNull(dtoConn.Secrets);
Assert.Equal(
"{\"endpoint\":\"opc.tcp://plc1:4840\",\"password\":\"s3cret\"}",
dtoConn.Secrets!.Values["PrimaryConfiguration"]);
Assert.Equal(
"{\"endpoint\":\"opc.tcp://plc2:4840\"}",
dtoConn.Secrets.Values["BackupConfiguration"]);
// The secret must not leak into any non-secret string property of the DTO.
Assert.DoesNotContain("s3cret", dtoConn.SiteIdentifier);
Assert.DoesNotContain("s3cret", dtoConn.Name);
Assert.DoesNotContain("s3cret", dtoConn.Protocol);
// Instance: template + site resolved by name/identifier (never numeric id).
var dtoInst = Assert.Single(dto.Instances);
Assert.Equal("Pump-001", dtoInst.UniqueName);
Assert.Equal("Pump", dtoInst.TemplateName);
Assert.Equal("north", dtoInst.SiteIdentifier);
Assert.Equal(InstanceState.Enabled, dtoInst.State);
// Areas don't travel yet (no Areas collection on the aggregate).
Assert.Null(dtoInst.AreaName);
// All four child collections survive.
var dtoAttr = Assert.Single(dtoInst.AttributeOverrides);
Assert.Equal("Setpoint", dtoAttr.AttributeName);
Assert.Equal("55", dtoAttr.OverrideValue);
var dtoAlarm = Assert.Single(dtoInst.AlarmOverrides);
Assert.Equal("HighPressure", dtoAlarm.AlarmCanonicalName);
Assert.Equal("{\"hi\":90}", dtoAlarm.TriggerConfigurationOverride);
Assert.Equal(1, dtoAlarm.PriorityLevelOverride);
var dtoNative = Assert.Single(dtoInst.NativeAlarmSourceOverrides);
Assert.Equal("PlcAlarms", dtoNative.SourceCanonicalName);
Assert.Equal("ns=2;s=Pump1", dtoNative.SourceReferenceOverride);
// Binding carries the RESOLVED connection NAME (not the numeric FK id).
var dtoBinding = Assert.Single(dtoInst.ConnectionBindings);
Assert.Equal("Pressure", dtoBinding.AttributeName);
Assert.Equal("PlcLink", dtoBinding.ConnectionName);
Assert.Equal("ns=2;s=Pump1.P", dtoBinding.DataSourceReferenceOverride);
}
[Fact]
public void ToDto_omits_secret_key_when_backup_configuration_is_empty()
{
var aggregate = MakeEmptyAggregate() with
{
Sites = new[] { new Site("S", "s") { Id = 1 } },
DataConnections = new[]
{
new DataConnection("c", "OpcUa", siteId: 1)
{
Id = 1,
PrimaryConfiguration = "{\"e\":\"x\"}",
BackupConfiguration = null,
},
},
};
var dto = new EntitySerializer().ToBundleContent(aggregate);
var dtoConn = Assert.Single(dto.DataConnections);
Assert.NotNull(dtoConn.Secrets);
Assert.True(dtoConn.Secrets!.Values.ContainsKey("PrimaryConfiguration"));
// Null source value => key omitted entirely (not an empty string).
Assert.False(dtoConn.Secrets.Values.ContainsKey("BackupConfiguration"));
}
[Fact]
public void Roundtrip_site_connection_instance_restores_config_from_secrets_and_binding_name()
{
var sut = new EntitySerializer();
var dto = sut.ToBundleContent(MakePopulatedSiteAggregate());
var rt = sut.FromBundleContent(dto);
// Site reconstructed with a synthetic id; identity + addresses preserved.
var rtSite = Assert.Single(rt.Sites);
Assert.Equal("north", rtSite.SiteIdentifier);
Assert.Equal("North Plant", rtSite.Name);
Assert.Equal("akka://sys@10.0.0.1:4053", rtSite.NodeAAddress);
// Data connection: protocol config restored from the SecretsBlock; owning
// site FK resolved to the reconstructed site's synthetic id.
var rtConn = Assert.Single(rt.DataConnections);
Assert.Equal("PlcLink", rtConn.Name);
Assert.Equal("OpcUa", rtConn.Protocol);
Assert.Equal(5, rtConn.FailoverRetryCount);
Assert.Equal(rtSite.Id, rtConn.SiteId);
Assert.Equal(
"{\"endpoint\":\"opc.tcp://plc1:4840\",\"password\":\"s3cret\"}",
rtConn.PrimaryConfiguration);
Assert.Equal("{\"endpoint\":\"opc.tcp://plc2:4840\"}", rtConn.BackupConfiguration);
// Instance: template + site FKs resolved to the reconstructed synthetic ids;
// Area never travels so AreaId stays null.
var rtInst = Assert.Single(rt.Instances);
Assert.Equal("Pump-001", rtInst.UniqueName);
Assert.Equal(InstanceState.Enabled, rtInst.State);
Assert.Equal(Assert.Single(rt.Templates).Id, rtInst.TemplateId);
Assert.Equal(rtSite.Id, rtInst.SiteId);
Assert.Null(rtInst.AreaId);
// Child overrides survive the round-trip.
Assert.Equal("Setpoint", Assert.Single(rtInst.AttributeOverrides).AttributeName);
Assert.Equal("HighPressure", Assert.Single(rtInst.AlarmOverrides).AlarmCanonicalName);
Assert.Equal("PlcAlarms", Assert.Single(rtInst.NativeAlarmSourceOverrides).SourceCanonicalName);
// The binding's resolved ConnectionName is preserved on the wire DTO so the
// importer (D1) can resolve it to a target-DB FK at apply time. The
// reconstructed entity carries AttributeName + override but leaves the
// numeric DataConnectionId at its unresolved placeholder (0).
var dtoBinding = Assert.Single(Assert.Single(dto.Instances).ConnectionBindings);
Assert.Equal("PlcLink", dtoBinding.ConnectionName);
var rtBinding = Assert.Single(rtInst.ConnectionBindings);
Assert.Equal("Pressure", rtBinding.AttributeName);
Assert.Equal("ns=2;s=Pump1.P", rtBinding.DataSourceReferenceOverride);
Assert.Equal(0, rtBinding.DataConnectionId);
}
}
@@ -14,7 +14,8 @@ public sealed class ManifestBuilderTests
public void Build_populates_summary_from_contents()
{
var sut = new ManifestBuilder();
var summary = new BundleSummary(2, 1, 0, 0, 0, 0, 0, 0);
// M8 (B3): summary now carries trailing Sites/DataConnections/Instances counts.
var summary = new BundleSummary(2, 1, 0, 0, 0, 0, 0, 0, Sites: 3, DataConnections: 4, Instances: 5);
var contents = new[]
{
new ManifestContentEntry("Template", "T1", 1, Array.Empty<string>()),
@@ -25,12 +26,17 @@ public sealed class ManifestBuilderTests
var manifest = sut.Build("env", "user", "1.0.0", encryption: null, summary, contents, contentBytes: new byte[] { 1, 2, 3 });
Assert.Equal(summary, manifest.Summary);
// New M8 summary counts flow through the builder verbatim.
Assert.Equal(3, manifest.Summary.Sites);
Assert.Equal(4, manifest.Summary.DataConnections);
Assert.Equal(5, manifest.Summary.Instances);
Assert.Equal(contents, manifest.Contents);
Assert.Equal("env", manifest.SourceEnvironment);
Assert.Equal("user", manifest.ExportedBy);
Assert.Equal("1.0.0", manifest.ScadaBridgeVersion);
Assert.Equal(1, manifest.BundleFormatVersion);
Assert.Equal("1.0", manifest.SchemaVersion);
// M8 (B3): schema minor bumped 1.0 → 1.1; format version stays 1.
Assert.Equal("1.1", manifest.SchemaVersion);
Assert.Null(manifest.Encryption);
}
@@ -100,4 +106,51 @@ public sealed class ManifestBuilderTests
Assert.Equal(ManifestValidationResult.Ok, result);
}
[Fact]
public void Validate_accepts_older_schemaVersion_1_0_manifest()
{
// An older "1.0" bundle (pre-M8) must still import: schema minor differences
// are additive and the validator gates only on BundleFormatVersion.
var bytes = Encoding.UTF8.GetBytes("legacy-bundle");
var hash = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
var manifest = new BundleManifest(
BundleFormatVersion: ManifestBuilder.CurrentBundleFormatVersion,
SchemaVersion: "1.0",
CreatedAtUtc: DateTimeOffset.UtcNow,
SourceEnvironment: "env",
ExportedBy: "u",
ScadaBridgeVersion: "v",
ContentHash: hash,
Encryption: null,
Summary: EmptySummary,
Contents: NoContents);
var result = new ManifestValidator().Validate(manifest, bytes);
Assert.Equal(ManifestValidationResult.Ok, result);
}
[Fact]
public void Validate_refuses_bundleFormatVersion_2()
{
// A future format-version 2 is a hard refusal (not a minor schema bump).
var bytes = Encoding.UTF8.GetBytes("future-bundle");
var hash = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
var manifest = new BundleManifest(
BundleFormatVersion: 2,
SchemaVersion: "1.1",
CreatedAtUtc: DateTimeOffset.UtcNow,
SourceEnvironment: "env",
ExportedBy: "u",
ScadaBridgeVersion: "v",
ContentHash: hash,
Encryption: null,
Summary: EmptySummary,
Contents: NoContents);
var result = new ManifestValidator().Validate(manifest, bytes);
Assert.Equal(ManifestValidationResult.UnsupportedFormatVersion, result);
}
}