Galaxy E2E — point at live writable historized attribute + MachineStatus

Pick a Galaxy attribute that actually exercises the full driver stack:
TestMachine_001.TestHistoryValue. Verified against the live dev-box ZB:
it's Int32, writable (security_classification = Operate), and historized
(HistoryExtension primitive). The query lives in
`gr/queries/attributes_extended.sql` — swap to any other writable
historized attribute via the same shape
(`WHERE is_historized = 1 AND security_classification > 0`).

Seed changes:
- Tag row: FullName = TestMachine_001.TestHistoryValue (Int32 / ReadWrite)
- VirtualTag renamed: `Doubled` → `MachineStatus` (Boolean), script returns
  `Source > 0`. Historized, so the write/subscribe exercise doubles as a
  historian-sink check once the alarm/write stages are enabled.
- Scripted alarm predicate reads the same Source and fires on `> 50`.
- Added ClusterNodeCredential(sa → p7-smoke-node) row so
  sp_GetCurrentGenerationForCluster's caller-binding check passes. Without
  this the server bootstrap fails with
  `Unauthorized: caller sa is not bound to NodeId p7-smoke-node`.

E2E script:
- Path-based NodeId defaults updated to match the new MachineStatus
  virtual tag.
- Added optional `-Username / -Password` parameters. Anonymous sessions
  still get denied against Operate-classified attributes (PR 26 /
  docs/Security.md); supplying `-Username writeop -Password writeop123`
  against the dev-box GLAuth exercises the reverse-bridge stage.
- Wired those credentials into every Invoke-Cli / Start-Process CLI
  invocation the script drives.

Anonymous smoke remains 3/7 pass (probe + source read + reverse-bridge
marked acl-expected INFO). A fuller run with
`-Username writeop -Password writeop123` requires also enabling LDAP +
a SecurityProfile that carries a UserName UserTokenPolicy — separate
config step tracked alongside #124 (3-user authz matrix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-24 18:04:39 -04:00
parent 69e1d320ac
commit ec1a5905bf
2 changed files with 58 additions and 22 deletions

View File

@@ -55,8 +55,10 @@
`reactor-1` → `Source` (Tag.Name). `reactor-1` → `Source` (Tag.Name).
.PARAMETER VirtualNodeId .PARAMETER VirtualNodeId
NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting). Same NodeId of the VirtualTag that computes MachineStatus = (Source > 0) (Phase 7
path-based scheme, ending in the VirtualTag.Name (`Doubled`). 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 .PARAMETER AlarmNodeId
NodeId of the scripted-alarm Condition (fires when Source > 50). Same NodeId of the scripted-alarm Condition (fires when Source > 50). Same
@@ -93,12 +95,19 @@
param( param(
[string]$OpcUaUrl = "opc.tcp://localhost:4840", [string]$OpcUaUrl = "opc.tcp://localhost:4840",
[string]$SourceNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source", [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]$AlarmNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/OverTemp",
[string]$AlarmTriggerValue = "75", [string]$AlarmTriggerValue = "75",
[int]$ChangeWaitSec = 10, [int]$ChangeWaitSec = 10,
[int]$AlarmWaitSec = 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" $ErrorActionPreference = "Stop"
@@ -108,6 +117,13 @@ $opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-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 = @() $results = @()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -117,7 +133,7 @@ $results = @()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
Write-Header "Probe" 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") { if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") {
Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)" Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)"
$results += @{ Passed = $true } $results += @{ Passed = $true }
@@ -134,7 +150,7 @@ if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
Write-Header "Source read" 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 $sourceValue = $null
if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") { if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") {
$sourceValue = $Matches[1].Trim() $sourceValue = $Matches[1].Trim()
@@ -158,7 +174,7 @@ if ([string]::IsNullOrEmpty($VirtualNodeId)) {
Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check" Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check"
} else { } else {
Write-Header "Virtual-tag bridge" 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]+)") { if ($vtRead.ExitCode -eq 0 -and $vtRead.Output -match "Value:\s+([^\r\n]+)") {
$vtValue = $Matches[1].Trim() $vtValue = $Matches[1].Trim()
Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)" Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)"
@@ -181,7 +197,7 @@ $stdout = New-TemporaryFile
$stderr = New-TemporaryFile $stderr = New-TemporaryFile
$subArgs = @($opcUaCli.PrefixArgs) + @( $subArgs = @($opcUaCli.PrefixArgs) + @(
"subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId, "subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId,
"-i", "500", "--duration", "$ChangeWaitSec") "-i", "500", "--duration", "$ChangeWaitSec") + $authArgs
$subProc = Start-Process -FilePath $opcUaCli.File ` $subProc = Start-Process -FilePath $opcUaCli.File `
-ArgumentList $subArgs -NoNewWindow -PassThru ` -ArgumentList $subArgs -NoNewWindow -PassThru `
-RedirectStandardOutput $stdout.FullName ` -RedirectStandardOutput $stdout.FullName `
@@ -214,8 +230,8 @@ if ($changeLines.Count -gt 0) {
Write-Header "Reverse bridge (OPC UA write)" Write-Header "Reverse bridge (OPC UA write)"
$writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write $writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write
$w = Invoke-Cli -Cli $opcUaCli -Args @( $w = Invoke-Cli -Cli $opcUaCli -Args (@(
"write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue") "write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue") + $authArgs)
if ($w.ExitCode -ne 0) { if ($w.ExitCode -ne 0) {
# Connection/protocol failure — still a test failure. # Connection/protocol failure — still a test failure.
Write-Fail "write CLI exit=$($w.ExitCode)" Write-Fail "write CLI exit=$($w.ExitCode)"
@@ -230,7 +246,7 @@ if ($w.ExitCode -ne 0) {
} elseif ($w.Output -match "Write successful") { } elseif ($w.Output -match "Write successful") {
# Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle. # Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle.
Start-Sleep -Seconds 2 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") { if ($r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
Write-Pass "write propagated — source reads back $writeValue" Write-Pass "write propagated — source reads back $writeValue"
$results += @{ Passed = $true } $results += @{ Passed = $true }

View File

@@ -71,6 +71,13 @@ INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, Dashb
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000, VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000,
'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke'); '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 -- 2. Generation (created Draft, flipped to Published at the end so insert order
-- constraints (one Draft per cluster, etc.) don't fight us). -- constraints (one Draft per cluster, etc.) don't fight us).
DECLARE @Gen bigint; DECLARE @Gen bigint;
@@ -106,30 +113,43 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'galaxy-smoke', 'Galaxy', N'{
}', 1); }', 1);
-- 6. One driver-sourced Tag bound to the Equipment. TagConfig is the Galaxy -- 6. One driver-sourced Tag bound to the Equipment. TagConfig is the Galaxy
-- fullRef ("DelmiaReceiver_001.DownloadPath" style); replace with a real -- fullRef; the EquipmentNodeWalker reads the JSON's FullName field and
-- attribute on this Galaxy. The script paths below use -- hands that string to the Galaxy driver as the MXAccess reference.
-- /lab-floor/galaxy-line/reactor-1/Source which the EquipmentNodeWalker -- Default points at TestMachine_001.TestHistoryValue — the live dev-box
-- emits + the DriverSubscriptionBridge maps to this driver fullRef. -- 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, INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent) AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Float64', 'Read', VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Int32', 'ReadWrite',
N'{"FullName":"REPLACE_WITH_REAL_GALAXY_ATTRIBUTE","DataType":"Float64"}', 0); N'{"FullName":"TestMachine_001.TestHistoryValue","DataType":"Int32"}', 0);
-- 7. Scripts (SourceHash is SHA-256 of SourceCode, computed externally — using -- 7. Scripts (SourceHash is SHA-256 of SourceCode, computed externally — using
-- a placeholder here; the engine recomputes on first use anyway). -- 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) INSERT dbo.Script(GenerationId, ScriptId, Name, SourceCode, SourceHash, Language)
VALUES VALUES
(@Gen, @VtScript, 'doubled-source', (@Gen, @VtScript, 'machine-status-predicate',
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) * 2.0;', N'return System.Convert.ToInt32(ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 0;',
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'), '0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'),
(@Gen, @AlScript, 'overtemp-predicate', (@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'); '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, INSERT dbo.VirtualTag(GenerationId, VirtualTagId, EquipmentId, Name, DataType,
ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) 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. -- 9. ScriptedAlarm — Active when Source > 50.
INSERT dbo.ScriptedAlarm(GenerationId, ScriptedAlarmId, EquipmentId, Name, AlarmType, INSERT dbo.ScriptedAlarm(GenerationId, ScriptedAlarmId, EquipmentId, Name, AlarmType,