diff --git a/scripts/e2e/test-galaxy.ps1 b/scripts/e2e/test-galaxy.ps1 index f13d58e..dc0e3e4 100644 --- a/scripts/e2e/test-galaxy.ps1 +++ b/scripts/e2e/test-galaxy.ps1 @@ -55,8 +55,10 @@ `reactor-1` → `Source` (Tag.Name). .PARAMETER VirtualNodeId - NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting). Same - path-based scheme, ending in the VirtualTag.Name (`Doubled`). + NodeId of the VirtualTag that computes MachineStatus = (Source > 0) (Phase 7 + scripting). Same path-based scheme, ending in the VirtualTag.Name + (`MachineStatus`). The tag is historized so the write/subscribe exercise + doubles as a historian-sink check. .PARAMETER AlarmNodeId NodeId of the scripted-alarm Condition (fires when Source > 50). Same @@ -93,12 +95,19 @@ param( [string]$OpcUaUrl = "opc.tcp://localhost:4840", [string]$SourceNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source", - [string]$VirtualNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Doubled", + [string]$VirtualNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/MachineStatus", [string]$AlarmNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/OverTemp", [string]$AlarmTriggerValue = "75", [int]$ChangeWaitSec = 10, [int]$AlarmWaitSec = 10, - [int]$HistoryLookbackSec = 3600 + [int]$HistoryLookbackSec = 3600, + # The default Phase 7 seed uses a Galaxy attribute with + # security_classification=Operate. Anonymous OPC UA sessions are denied writes + # against Operate-classified tags (PR 26 / docs/Security.md). Supply an LDAP + # user with WriteOperate to exercise the reverse-bridge stage — e.g. + # `-Username writeop -Password writeop123` against the dev-box GLAuth. + [string]$Username = "", + [string]$Password = "" ) $ErrorActionPreference = "Stop" @@ -108,6 +117,13 @@ $opcUaCli = Get-CliInvocation ` -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` -ExeName "otopcua-cli" +# Auth-extension helper — appends `-U / -P` to the CLI args when credentials +# were supplied. Stays empty for anonymous runs so the default smoke path +# doesn't require an LDAP round-trip. +$authArgs = @() +if ($Username) { $authArgs += @("-U", $Username) } +if ($Password) { $authArgs += @("-P", $Password) } + $results = @() # --------------------------------------------------------------------------- @@ -117,7 +133,7 @@ $results = @() # --------------------------------------------------------------------------- Write-Header "Probe" -$probe = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) +$probe = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs) if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") { Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)" $results += @{ Passed = $true } @@ -134,7 +150,7 @@ if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") { # --------------------------------------------------------------------------- Write-Header "Source read" -$sourceRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) +$sourceRead = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs) $sourceValue = $null if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") { $sourceValue = $Matches[1].Trim() @@ -158,7 +174,7 @@ if ([string]::IsNullOrEmpty($VirtualNodeId)) { Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check" } else { Write-Header "Virtual-tag bridge" - $vtRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId) + $vtRead = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId) + $authArgs) if ($vtRead.ExitCode -eq 0 -and $vtRead.Output -match "Value:\s+([^\r\n]+)") { $vtValue = $Matches[1].Trim() Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)" @@ -181,7 +197,7 @@ $stdout = New-TemporaryFile $stderr = New-TemporaryFile $subArgs = @($opcUaCli.PrefixArgs) + @( "subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId, - "-i", "500", "--duration", "$ChangeWaitSec") + "-i", "500", "--duration", "$ChangeWaitSec") + $authArgs $subProc = Start-Process -FilePath $opcUaCli.File ` -ArgumentList $subArgs -NoNewWindow -PassThru ` -RedirectStandardOutput $stdout.FullName ` @@ -214,8 +230,8 @@ if ($changeLines.Count -gt 0) { Write-Header "Reverse bridge (OPC UA write)" $writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write -$w = Invoke-Cli -Cli $opcUaCli -Args @( - "write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue") +$w = Invoke-Cli -Cli $opcUaCli -Args (@( + "write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue") + $authArgs) if ($w.ExitCode -ne 0) { # Connection/protocol failure — still a test failure. Write-Fail "write CLI exit=$($w.ExitCode)" @@ -230,7 +246,7 @@ if ($w.ExitCode -ne 0) { } elseif ($w.Output -match "Write successful") { # Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle. Start-Sleep -Seconds 2 - $r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $r = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs) if ($r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") { Write-Pass "write propagated — source reads back $writeValue" $results += @{ Passed = $true } diff --git a/scripts/smoke/seed-phase-7-smoke.sql b/scripts/smoke/seed-phase-7-smoke.sql index f7d49fa..64b1715 100644 --- a/scripts/smoke/seed-phase-7-smoke.sql +++ b/scripts/smoke/seed-phase-7-smoke.sql @@ -71,6 +71,13 @@ INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, Dashb VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000, 'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke'); +-- sp_GetCurrentGenerationForCluster gates access by SUSER_SNAME() against +-- ClusterNodeCredential; without this binding the Server bootstrap fails with +-- `Unauthorized: caller sa is not bound to NodeId p7-smoke-node`. Dev Docker +-- SQL runs with `sa`; production deploys would rotate to a per-node login. +INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy) +VALUES (@NodeId, N'SqlLogin', N'sa', 1, N'p7-smoke'); + -- 2. Generation (created Draft, flipped to Published at the end so insert order -- constraints (one Draft per cluster, etc.) don't fight us). DECLARE @Gen bigint; @@ -106,30 +113,43 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'galaxy-smoke', 'Galaxy', N'{ }', 1); -- 6. One driver-sourced Tag bound to the Equipment. TagConfig is the Galaxy --- fullRef ("DelmiaReceiver_001.DownloadPath" style); replace with a real --- attribute on this Galaxy. The script paths below use --- /lab-floor/galaxy-line/reactor-1/Source which the EquipmentNodeWalker --- emits + the DriverSubscriptionBridge maps to this driver fullRef. +-- fullRef; the EquipmentNodeWalker reads the JSON's FullName field and +-- hands that string to the Galaxy driver as the MXAccess reference. +-- Default points at TestMachine_001.TestHistoryValue — the live dev-box +-- Galaxy ships it as Int32 + writable (security_classification=Operate) +-- + historized (HistoryExtension primitive), so the E2E script can +-- exercise read + write + subscribe + alarm + history against the +-- same attribute. Swap to any other writable historized attribute on +-- this Galaxy by re-running `gr/queries/attributes_extended.sql` with +-- `WHERE is_historized=1 AND security_classification > 0`. INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType, AccessLevel, TagConfig, WriteIdempotent) -VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Float64', 'Read', - N'{"FullName":"REPLACE_WITH_REAL_GALAXY_ATTRIBUTE","DataType":"Float64"}', 0); +VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Int32', 'ReadWrite', + N'{"FullName":"TestMachine_001.TestHistoryValue","DataType":"Int32"}', 0); -- 7. Scripts (SourceHash is SHA-256 of SourceCode, computed externally — using -- a placeholder here; the engine recomputes on first use anyway). +-- +-- MachineStatus predicate — the dynamic "is the machine running?" status +-- derived from the raw Source value. Boolean: true when Source > 0. Matches +-- the shape Aveva operators typically want on a machine-status tile (green +-- when above zero, otherwise grey) without needing a separate threshold +-- attribute. Rename + re-threshold here to mirror site semantics. INSERT dbo.Script(GenerationId, ScriptId, Name, SourceCode, SourceHash, Language) VALUES - (@Gen, @VtScript, 'doubled-source', - N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) * 2.0;', + (@Gen, @VtScript, 'machine-status-predicate', + N'return System.Convert.ToInt32(ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 0;', '0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'), (@Gen, @AlScript, 'overtemp-predicate', - N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 50.0;', + N'return System.Convert.ToInt32(ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 50;', '0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'); --- 8. VirtualTag — derived value computed by Roslyn each time Source changes. +-- 8. VirtualTag — MachineStatus boolean computed by Roslyn each time Source +-- changes. Historized so the dashboard can plot a running/idle timeline +-- next to the raw TestHistoryValue trend from Aveva Historian. INSERT dbo.VirtualTag(GenerationId, VirtualTagId, EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) -VALUES (@Gen, @VtId, @EqId, 'Doubled', 'Float64', @VtScript, 1, NULL, 0, 1); +VALUES (@Gen, @VtId, @EqId, 'MachineStatus', 'Boolean', @VtScript, 1, NULL, 1, 1); -- 9. ScriptedAlarm — Active when Source > 50. INSERT dbo.ScriptedAlarm(GenerationId, ScriptedAlarmId, EquipmentId, Name, AlarmType,