chore: add isolated Tornado readiness probe
This commit is contained in:
@@ -59,6 +59,8 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
|
||||
-c Debug -p:Platform=x64 -- --dry-run
|
||||
```
|
||||
|
||||
격리 Tornado TEST의 프로세스·Network Server 포트·loopback 주소·LISTEN 소유권은 COM을 열기 전에 [읽기 전용 점검 스크립트](scripts/Inspect-TornadoTestIsolation.ps1)로 확인합니다. 설치 폴더의 다른 Tornado2 버전 EXE는 현재 사용자 설정과 출력 장치를 공유할 수 있으므로 PGM 옆에서 TEST 대용으로 실행하지 않습니다.
|
||||
|
||||
별도 Test 인스턴스 검증은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md)의 단계별 CLI를 사용합니다. `--test-plan`은 절대 경로 로컬 JSON과 씬 자산만 확인하며 엔진/COM을 만들지 않습니다. PGM/PROGRAM이 전혀 없는 격리 출력에서만 `--test-connect`로 연결·해제를 확인한 뒤 `--test-sequence`로 `PREPARE → TAKE IN → NEXT → TAKE OUT`을 실행합니다. Test 명령은 `MBN_STOCK_PLAYOUT_*` 환경 override와 Live 설정을 거부하고 자동 재연결·재생을 하지 않습니다. 승인된 Test 씬 후보는 basename `5001`, `5006`이며 실제 자산 경로는 저장소에 기록하지 않습니다.
|
||||
|
||||
중요: 앱의 기본 `DryRun`, `--probe`, `--dry-run`, `--test-plan`은 `KTAPConnect`를 호출하지 않습니다. 따라서 이 단계에서 Tornado2의 `View > Network Monitoring Window`에 앱이 보낸 통신 기록이 없는 것은 정상입니다. 이 CLI 절차에서는 모든 안전 게이트를 통과한 격리 `Test`의 `--test-connect`에서 처음 네트워크 기록을 기대하며, 별도로 승인된 UI Test 실행도 기록을 만들 수 있습니다. 사용할 포트는 예제 숫자가 아니라 해당 격리 Test Tornado의 `Tools > Option > Control > Network Server > TCP Port` 실값이어야 합니다.
|
||||
|
||||
@@ -163,6 +163,10 @@ powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
|
||||
실제 Test 검증은 아래 세 단계를 순서대로 사용합니다. 세 명령 모두 절대 경로의 일반 로컬 JSON 파일과 `--acknowledge-isolated-non-program-test-output` 승인이 필요합니다. `MBN_STOCK_PLAYOUT_*` 환경 변수가 하나라도 존재하면 파일과 환경의 합성 결과가 달라지는 것을 막기 위해 엔진 생성 전에 거부합니다. JSON은 반드시 `mode: "Test"`, `trustedLiveOutputEnabled: false`여야 하며 기존 Test 안전 게이트를 그대로 통과해야 합니다.
|
||||
|
||||
이 장비의 Tornado2 설정은 실행 파일별 프로필이 아니라 현재 사용자 공용 `HKCU\Software\Visual Research Inc\Tornado2\Option\Control`에 저장됩니다. 설치된 Tornado2 매뉴얼 286쪽도 프로그램 실행 시 이 Network Server 설정의 자동 시작을 설명하며 별도 명령행 프로필이나 다중 인스턴스 절차를 제시하지 않습니다. 따라서 `C:\Tornado2`에 함께 설치된 다른 버전 EXE를 PGM 옆에서 실행해 TEST로 간주하지 않습니다. 동일 포트, 동일 출력 보드와 동일 사용자 설정을 공유할 수 있습니다.
|
||||
|
||||
권장 구성은 별도 TEST 장비 또는 독립 VM/Windows 사용자 프로필에서 출력 보드를 preview 전용 또는 `None`으로 두고, 그 환경에 Tornado2를 정확히 하나만 실행하는 것입니다. 같은 장비를 사용해야 한다면 방송 운영자가 승인한 정비 시간에 PGM을 정상 종료하고 Tornado UI에서 Network Server와 출력 채널을 TEST 전용으로 구성하며, 창 제목에 `TEST` 식별자가 나타나는 공식 운영 절차까지 확인한 뒤 진행합니다. 실행 중 PGM의 레지스트리나 옵션을 자동 변경하거나 다른 버전 EXE를 병렬 실행하지 않습니다.
|
||||
|
||||
먼저 씬 파일과 설정만 검사합니다. 이 단계는 엔진·STA·COM을 생성하지 않으며 실행 중 프로세스의 적격성도 아직 판단하지 않습니다. 승인된 Test 씬 후보인 `5001.t2s`와 `5006.t2s`를 사용할 때 로컬 JSON의 외부 `sceneDirectory` 및 `testSceneAllowlist`에는 각각 basename `5001`, `5006`을 설정합니다. 실제 절대 경로는 로컬 JSON에만 두고 저장소 문서나 예제 설정에 기록하지 않습니다.
|
||||
|
||||
```powershell
|
||||
@@ -179,6 +183,17 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
|
||||
--observe-ms 5000
|
||||
```
|
||||
|
||||
자산 검사가 끝나면 COM을 만들기 전, 즉 `--test-connect` 직전에 다음 읽기 전용 점검으로 프로세스, 현재 사용자 Network Server 설정과 LISTEN 소유권을 함께 확인합니다. `ExpectedTestWindowTitle`은 실제 창 제목과 대소문자만 무시하고 정확히 같아야 하며 독립된 `TEST` 토큰이 필요합니다. 결과에는 PID, IP 주소와 실제 창 제목을 기록하지 않습니다.
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File .\scripts\Inspect-TornadoTestIsolation.ps1 `
|
||||
-ExpectedTcpPort <Test-Tornado-Network-Server-TCP-Port> `
|
||||
-ExpectedTestWindowTitle 'Tornado2 TEST'
|
||||
```
|
||||
|
||||
`ready: true`인 경우에만 `--test-connect`로 진행합니다. 이 점검은 COM과 `KTAPConnect`를 호출하지 않으므로 Network Monitoring 기록이 없어야 합니다. `program-window-detected`, `network-address-not-loopback`, `listener-address-not-loopback`, `listener-not-owned-by-test-instance` 중 하나라도 있으면 현재 실행 환경은 격리 TEST가 아닙니다. 레지스트리 설정뿐 아니라 실제 LISTEN endpoint도 모두 loopback이어야 하며 wildcard(`0.0.0.0`, `::`) 리스너는 거부합니다.
|
||||
|
||||
별도 Test Tornado만 정확히 하나 실행되고 PGM/PROGRAM 창이 전혀 없는 것을 사람이 다시 확인한 뒤 연결만 검증합니다. 이 명령은 씬이나 출력 상태를 변경하지 않고 `Connect → Disconnect`만 요청합니다.
|
||||
|
||||
명령 전에 Network Monitoring을 열고 `TCPSession`과 로그 시각을 기준선으로 기록합니다. `--test-plan`에는 변화가 없어야 합니다. `--test-connect`는 짧게 `Connect → Disconnect`하므로 세션 수가 곧 원래 값으로 돌아갈 수 있습니다. 일시적인 세션 수만 보지 말고 같은 시각의 `[R]`/`[S]` 양방향 기록을 확인합니다. `[R]`만 있고 `[S]`가 없거나 양쪽 모두 없으면 명령을 반복하지 말고 중단합니다. JSON의 `completed: true`, `outcomeUnknown: false`, `lastKtapConnectState: "accepted-unconfirmed"`와 운영자가 본 모니터 기록을 함께 확인한 뒤에만 5001→5006 시퀀스로 진행합니다.
|
||||
|
||||
218
scripts/Inspect-TornadoTestIsolation.ps1
Normal file
218
scripts/Inspect-TornadoTestIsolation.ps1
Normal file
@@ -0,0 +1,218 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateRange(1, 65535)]
|
||||
[int]$ExpectedTcpPort,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateLength(1, 128)]
|
||||
[string]$ExpectedTestWindowTitle
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-SafeResult {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object]$Value,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$ExitCode
|
||||
)
|
||||
|
||||
$Value | ConvertTo-Json -Depth 6
|
||||
exit $ExitCode
|
||||
}
|
||||
|
||||
function Test-ProgramWindowTitle {
|
||||
param([AllowEmptyString()][string]$Title)
|
||||
|
||||
return $Title -match '(?i)(^|[^A-Z0-9])(PGM|PROGRAM)([^A-Z0-9]|$)'
|
||||
}
|
||||
|
||||
try {
|
||||
$expectedTitle = $ExpectedTestWindowTitle.Trim()
|
||||
if (-not $expectedTitle.StartsWith('Tornado2', [StringComparison]::OrdinalIgnoreCase) -or
|
||||
$expectedTitle -notmatch '(?i)(^|[^A-Z0-9])TEST([^A-Z0-9]|$)' -or
|
||||
(Test-ProgramWindowTitle $expectedTitle)) {
|
||||
Write-SafeResult -ExitCode 64 -Value ([ordered]@{
|
||||
command = 'test-isolation-probe'
|
||||
ready = $false
|
||||
errorCode = 'invalid-test-window-title'
|
||||
connectRequestIssued = $false
|
||||
comActivationAttempted = $false
|
||||
ktapConnectAttempted = $false
|
||||
})
|
||||
}
|
||||
|
||||
$processInspectionAvailable = $true
|
||||
$tornadoProcesses = @()
|
||||
try {
|
||||
foreach ($process in Get-Process -ErrorAction Stop) {
|
||||
try {
|
||||
if (-not $process.ProcessName.StartsWith(
|
||||
'Tornado2',
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$title = $process.MainWindowTitle
|
||||
$tornadoProcesses += [pscustomobject]@{
|
||||
Id = $process.Id
|
||||
IsExpectedTest = [string]::Equals(
|
||||
$title,
|
||||
$expectedTitle,
|
||||
[StringComparison]::OrdinalIgnoreCase)
|
||||
IsProgram = Test-ProgramWindowTitle $title
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$processInspectionAvailable = $false
|
||||
}
|
||||
finally {
|
||||
$process.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$processInspectionAvailable = $false
|
||||
$tornadoProcesses = @()
|
||||
}
|
||||
|
||||
$eligibleProcesses = @($tornadoProcesses | Where-Object IsExpectedTest)
|
||||
$programProcesses = @($tornadoProcesses | Where-Object IsProgram)
|
||||
|
||||
$registryAvailable = $false
|
||||
$configuredPortMatches = $false
|
||||
$networkAddressIsLoopback = $false
|
||||
$networkAutoStart = $false
|
||||
try {
|
||||
$controlKey = Get-Item -LiteralPath (
|
||||
'Registry::HKEY_CURRENT_USER\Software\Visual Research Inc\Tornado2\Option\Control')
|
||||
$configuredPort = [int]$controlKey.GetValue('Network TCP Port', 0)
|
||||
$configuredAddress = [string]$controlKey.GetValue('Network Address', '')
|
||||
$networkAutoStart = [int]$controlKey.GetValue('Network Auto Start', 0) -ne 0
|
||||
$parsedAddress = $null
|
||||
$addressParsed = [Net.IPAddress]::TryParse($configuredAddress, [ref]$parsedAddress)
|
||||
$networkAddressIsLoopback = $addressParsed -and
|
||||
[Net.IPAddress]::IsLoopback($parsedAddress)
|
||||
$configuredPortMatches = $configuredPort -eq $ExpectedTcpPort
|
||||
$registryAvailable = $true
|
||||
}
|
||||
catch {
|
||||
$registryAvailable = $false
|
||||
}
|
||||
|
||||
$listenerInspectionAvailable = $true
|
||||
$listeners = @()
|
||||
try {
|
||||
$listeners = @(Get-NetTCPConnection `
|
||||
-State Listen `
|
||||
-LocalPort $ExpectedTcpPort `
|
||||
-ErrorAction Stop)
|
||||
}
|
||||
catch [Microsoft.PowerShell.Cmdletization.Cim.CimJobException] {
|
||||
# Get-NetTCPConnection uses this exception for an empty query on some Windows builds.
|
||||
$listeners = @()
|
||||
}
|
||||
catch {
|
||||
$listenerInspectionAvailable = $false
|
||||
$listeners = @()
|
||||
}
|
||||
|
||||
$listenerAddressesAreLoopback = $listeners.Count -gt 0
|
||||
foreach ($listener in $listeners) {
|
||||
$parsedListenerAddress = $null
|
||||
$listenerAddressParsed = [Net.IPAddress]::TryParse(
|
||||
[string]$listener.LocalAddress,
|
||||
[ref]$parsedListenerAddress)
|
||||
if (-not $listenerAddressParsed -or
|
||||
-not [Net.IPAddress]::IsLoopback($parsedListenerAddress)) {
|
||||
$listenerAddressesAreLoopback = $false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$eligibleProcessIds = @($eligibleProcesses | ForEach-Object Id)
|
||||
$listenerOwnerIds = @($listeners | ForEach-Object OwningProcess | Sort-Object -Unique)
|
||||
$listenerOwnedOnlyByTest = $listeners.Count -gt 0 -and
|
||||
$listenerOwnerIds.Count -gt 0 -and
|
||||
@($listenerOwnerIds | Where-Object { $_ -notin $eligibleProcessIds }).Count -eq 0
|
||||
|
||||
$reasons = [Collections.Generic.List[string]]::new()
|
||||
if (-not $processInspectionAvailable) {
|
||||
$reasons.Add('process-inspection-unavailable')
|
||||
}
|
||||
if ($tornadoProcesses.Count -ne 1) {
|
||||
$reasons.Add('tornado-process-count-not-one')
|
||||
}
|
||||
if ($eligibleProcesses.Count -ne 1) {
|
||||
$reasons.Add('test-window-not-exact-match')
|
||||
}
|
||||
if ($programProcesses.Count -gt 0) {
|
||||
$reasons.Add('program-window-detected')
|
||||
}
|
||||
if (-not $registryAvailable) {
|
||||
$reasons.Add('network-settings-unavailable')
|
||||
}
|
||||
elseif (-not $configuredPortMatches) {
|
||||
$reasons.Add('network-tcp-port-mismatch')
|
||||
}
|
||||
if ($registryAvailable -and -not $networkAddressIsLoopback) {
|
||||
$reasons.Add('network-address-not-loopback')
|
||||
}
|
||||
if (-not $listenerInspectionAvailable) {
|
||||
$reasons.Add('listener-inspection-unavailable')
|
||||
}
|
||||
elseif ($listeners.Count -eq 0) {
|
||||
$reasons.Add('network-server-not-listening')
|
||||
}
|
||||
else {
|
||||
if (-not $listenerAddressesAreLoopback) {
|
||||
$reasons.Add('listener-address-not-loopback')
|
||||
}
|
||||
if (-not $listenerOwnedOnlyByTest) {
|
||||
$reasons.Add('listener-not-owned-by-test-instance')
|
||||
}
|
||||
}
|
||||
|
||||
$ready = $reasons.Count -eq 0
|
||||
Write-SafeResult -ExitCode $(if ($ready) { 0 } else { 3 }) -Value ([ordered]@{
|
||||
command = 'test-isolation-probe'
|
||||
ready = $ready
|
||||
expectedTcpPort = $ExpectedTcpPort
|
||||
process = [ordered]@{
|
||||
inspectionAvailable = $processInspectionAvailable
|
||||
totalCount = $tornadoProcesses.Count
|
||||
eligibleTestCount = $eligibleProcesses.Count
|
||||
programWindowDetected = $programProcesses.Count -gt 0
|
||||
}
|
||||
networkServer = [ordered]@{
|
||||
settingsAvailable = $registryAvailable
|
||||
configuredPortMatches = $configuredPortMatches
|
||||
addressIsLoopback = $networkAddressIsLoopback
|
||||
autoStartEnabled = $networkAutoStart
|
||||
listenerInspectionAvailable = $listenerInspectionAvailable
|
||||
listenerEndpointCount = $listeners.Count
|
||||
listenerAddressesAreLoopback = $listenerAddressesAreLoopback
|
||||
listenerOwnedOnlyByTest = $listenerOwnedOnlyByTest
|
||||
}
|
||||
reasons = @($reasons)
|
||||
connectRequestIssued = $false
|
||||
comActivationAttempted = $false
|
||||
lastKtapConnectState = 'not-attempted'
|
||||
ktapConnectAttempted = $false
|
||||
networkMonitoringRecordExpected = $false
|
||||
networkMonitoringCheckRequired = $false
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Write-SafeResult -ExitCode 70 -Value ([ordered]@{
|
||||
command = 'test-isolation-probe'
|
||||
ready = $false
|
||||
errorCode = 'internal-probe-failure'
|
||||
connectRequestIssued = $false
|
||||
comActivationAttempted = $false
|
||||
ktapConnectAttempted = $false
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user