test(ft546): verify read-only shadow operation
This commit is contained in:
54
tools/shadow/Capture-StableWcsManifest.ps1
Normal file
54
tools/shadow/Capture-StableWcsManifest.ps1
Normal file
@@ -0,0 +1,54 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$StableArtifact,
|
||||
[int]$StablePid = 0,
|
||||
[string]$ServiceName,
|
||||
[string[]]$Configuration = @(),
|
||||
[Parameter(Mandatory = $true)][string]$OutputJson
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
|
||||
|
||||
$artifact = Resolve-ShadowFile $StableArtifact
|
||||
$service = $null
|
||||
if ($ServiceName) {
|
||||
$service = Get-CimInstance Win32_Service -Filter "Name='$ServiceName'" `
|
||||
-ErrorAction Stop
|
||||
if ($StablePid -eq 0) { $StablePid = [int]$service.ProcessId }
|
||||
}
|
||||
elseif ($StablePid -gt 0) {
|
||||
try {
|
||||
$service = Get-CimInstance Win32_Service -Filter "ProcessId=$StablePid" `
|
||||
-ErrorAction Stop | Select-Object -First 1
|
||||
}
|
||||
catch { $service = $null }
|
||||
}
|
||||
if ($StablePid -le 0) { throw 'STABLE_PID_OR_SERVICE_REQUIRED' }
|
||||
|
||||
$process = Get-Process -Id $StablePid -ErrorAction Stop
|
||||
$configurationEvidence = @($Configuration | ForEach-Object {
|
||||
$path = Resolve-ShadowFile $_
|
||||
[pscustomobject]@{
|
||||
path = $path
|
||||
sha256 = Get-ShadowSha256 $path
|
||||
}
|
||||
})
|
||||
$manifest = [pscustomobject]@{
|
||||
schemaVersion = 1
|
||||
capturedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
status = 'PASS'
|
||||
artifactPath = $artifact
|
||||
artifactSha256 = Get-ShadowSha256 $artifact
|
||||
pid = [int]$process.Id
|
||||
processStartTimeUtc = $process.StartTime.ToUniversalTime().ToString('o')
|
||||
executablePath = $process.Path
|
||||
commandLine = Get-ShadowCommandLine $process.Id
|
||||
serviceName = if ($null -eq $service) { $null } else { $service.Name }
|
||||
serviceState = if ($null -eq $service) { $null } else { $service.State }
|
||||
configuration = $configurationEvidence
|
||||
listeningPorts = @(Get-ShadowListeningPorts $process.Id)
|
||||
}
|
||||
Write-ShadowJson -Value $manifest -Path $OutputJson
|
||||
return $manifest
|
||||
167
tools/shadow/Export-Ft546ShadowEvidence.ps1
Normal file
167
tools/shadow/Export-Ft546ShadowEvidence.ps1
Normal file
@@ -0,0 +1,167 @@
|
||||
[CmdletBinding(DefaultParameterSetName = 'Snapshot')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Snapshot')]
|
||||
[string]$SnapshotDirectory,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Database')]
|
||||
[string]$EvidenceServer,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Database')]
|
||||
[string]$EvidenceDatabase,
|
||||
[Parameter(ParameterSetName = 'Database')][string]$EvidenceUsername,
|
||||
[Parameter(ParameterSetName = 'Database')][switch]$IntegratedSecurity,
|
||||
[Parameter(Mandatory = $true)][guid]$RunId,
|
||||
[Parameter(Mandatory = $true)][string]$OutputDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
|
||||
|
||||
$requiredFiles = @(
|
||||
'cycles.csv', 'cycles.json', 'protocol-differences.csv',
|
||||
'algorithm-differences.csv', 'insufficient-facts.csv',
|
||||
'startup-checks.json'
|
||||
)
|
||||
$output = Resolve-ShadowDirectory $OutputDirectory -Create
|
||||
if (@(Get-ChildItem -LiteralPath $output -Force).Count -gt 0) {
|
||||
throw 'EVIDENCE_EXPORT_DIRECTORY_MUST_BE_EMPTY'
|
||||
}
|
||||
|
||||
function Convert-DataTableRows {
|
||||
param([System.Data.DataTable]$Table)
|
||||
return @($Table.Rows | ForEach-Object {
|
||||
$value = [ordered]@{}
|
||||
foreach ($column in $Table.Columns) {
|
||||
$cell = $_[$column.ColumnName]
|
||||
$value[$column.ColumnName] = if ($cell -is [DBNull]) { $null } else { $cell }
|
||||
}
|
||||
[pscustomobject]$value
|
||||
})
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ParameterSetName -eq 'Snapshot') {
|
||||
$snapshot = Resolve-ShadowDirectory $SnapshotDirectory
|
||||
foreach ($name in $requiredFiles) {
|
||||
$source = Resolve-ShadowFile (Join-Path $snapshot $name)
|
||||
Copy-Item -LiteralPath $source -Destination (Join-Path $output $name)
|
||||
}
|
||||
}
|
||||
else {
|
||||
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
|
||||
$builder.DataSource = $EvidenceServer
|
||||
$builder.InitialCatalog = $EvidenceDatabase
|
||||
$builder.Encrypt = $true
|
||||
$builder.TrustServerCertificate = $false
|
||||
$builder.ConnectTimeout = 5
|
||||
if ($IntegratedSecurity.IsPresent) {
|
||||
$builder.IntegratedSecurity = $true
|
||||
}
|
||||
else {
|
||||
if ([string]::IsNullOrWhiteSpace($EvidenceUsername)) {
|
||||
throw 'EVIDENCE_USERNAME_REQUIRED'
|
||||
}
|
||||
$password = [Environment]::GetEnvironmentVariable(
|
||||
'FT546_SHADOW_EVIDENCE_PASSWORD', 'Process')
|
||||
if ([string]::IsNullOrWhiteSpace($password)) {
|
||||
throw 'FT546_SHADOW_EVIDENCE_PASSWORD_REQUIRED'
|
||||
}
|
||||
$builder.UserID = $EvidenceUsername
|
||||
$builder.Password = $password
|
||||
}
|
||||
$connection = New-Object System.Data.SqlClient.SqlConnection $builder.ConnectionString
|
||||
function Invoke-EvidenceQuery {
|
||||
param([string]$Sql)
|
||||
$command = $connection.CreateCommand()
|
||||
$command.CommandTimeout = 10
|
||||
$command.CommandText = $Sql
|
||||
$parameter = $command.Parameters.Add('@run_id',
|
||||
[System.Data.SqlDbType]::UniqueIdentifier)
|
||||
$parameter.Value = $RunId
|
||||
$adapter = New-Object System.Data.SqlClient.SqlDataAdapter $command
|
||||
$table = New-Object System.Data.DataTable
|
||||
[void]$adapter.Fill($table)
|
||||
return $table
|
||||
}
|
||||
try {
|
||||
$connection.Open()
|
||||
$cycles = Invoke-EvidenceQuery @'
|
||||
SELECT cycle_id,run_id,input_batch_hash,parent_task_id,phase,route_version,
|
||||
status,reason_code,database_captured_at,completed_at,evidence_hash
|
||||
FROM dbo.shadow_cycle WHERE run_id=@run_id ORDER BY completed_at,cycle_id
|
||||
'@
|
||||
Convert-DataTableRows $cycles | Export-Csv -NoTypeInformation `
|
||||
-Encoding UTF8 -LiteralPath (Join-Path $output 'cycles.csv')
|
||||
$protocol = Invoke-EvidenceQuery @'
|
||||
SELECT d.difference_id,d.cycle_id,d.category,d.reason_code,
|
||||
d.expected_json,d.actual_json
|
||||
FROM dbo.shadow_difference d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id
|
||||
WHERE c.run_id=@run_id AND d.category LIKE 'PROTOCOL_%'
|
||||
ORDER BY d.difference_id
|
||||
'@
|
||||
Convert-DataTableRows $protocol | Export-Csv -NoTypeInformation `
|
||||
-Encoding UTF8 -LiteralPath (Join-Path $output 'protocol-differences.csv')
|
||||
$algorithm = Invoke-EvidenceQuery @'
|
||||
SELECT d.difference_id,d.cycle_id,d.category,d.reason_code,
|
||||
d.expected_json,d.actual_json
|
||||
FROM dbo.shadow_difference d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id
|
||||
WHERE c.run_id=@run_id AND d.category LIKE 'ALGORITHM_%'
|
||||
ORDER BY d.difference_id
|
||||
'@
|
||||
Convert-DataTableRows $algorithm | Export-Csv -NoTypeInformation `
|
||||
-Encoding UTF8 -LiteralPath (Join-Path $output 'algorithm-differences.csv')
|
||||
$insufficient = Invoke-EvidenceQuery @'
|
||||
SELECT cycle_id,parent_task_id,phase,status,reason_code,database_captured_at,
|
||||
completed_at,evidence_hash
|
||||
FROM dbo.shadow_cycle
|
||||
WHERE run_id=@run_id AND status='INSUFFICIENT_FACTS'
|
||||
ORDER BY completed_at,cycle_id
|
||||
'@
|
||||
Convert-DataTableRows $insufficient | Export-Csv -NoTypeInformation `
|
||||
-Encoding UTF8 -LiteralPath (Join-Path $output 'insufficient-facts.csv')
|
||||
$startup = Invoke-EvidenceQuery @'
|
||||
SELECT check_id,check_name,status,checked_at FROM dbo.shadow_startup_check
|
||||
WHERE checked_at >= (SELECT started_at FROM dbo.shadow_run WHERE run_id=@run_id)
|
||||
ORDER BY check_id
|
||||
'@
|
||||
Convert-DataTableRows $startup | ConvertTo-Json -Depth 6 | `
|
||||
Set-Content -LiteralPath (Join-Path $output 'startup-checks.json') `
|
||||
-Encoding UTF8
|
||||
$cycleJson = [ordered]@{
|
||||
cycles = Convert-DataTableRows $cycles
|
||||
decisions = Convert-DataTableRows (Invoke-EvidenceQuery `
|
||||
'SELECT d.* FROM dbo.shadow_decision d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id WHERE c.run_id=@run_id ORDER BY d.cycle_id')
|
||||
encodedWrites = Convert-DataTableRows (Invoke-EvidenceQuery `
|
||||
'SELECT w.* FROM dbo.shadow_encoded_write w JOIN dbo.shadow_cycle c ON c.cycle_id=w.cycle_id WHERE c.run_id=@run_id ORDER BY w.cycle_id,w.write_sequence')
|
||||
differences = Convert-DataTableRows (Invoke-EvidenceQuery `
|
||||
'SELECT d.* FROM dbo.shadow_difference d JOIN dbo.shadow_cycle c ON c.cycle_id=d.cycle_id WHERE c.run_id=@run_id ORDER BY d.difference_id')
|
||||
}
|
||||
$cycleJson | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath `
|
||||
(Join-Path $output 'cycles.json') -Encoding UTF8
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $connection) { $connection.Dispose() }
|
||||
[Environment]::SetEnvironmentVariable(
|
||||
'FT546_SHADOW_EVIDENCE_PASSWORD', $null, 'Process')
|
||||
}
|
||||
}
|
||||
|
||||
$files = @($requiredFiles | Sort-Object | ForEach-Object {
|
||||
$path = Resolve-ShadowFile (Join-Path $output $_)
|
||||
[pscustomobject]@{
|
||||
name = $_
|
||||
length = (Get-Item -LiteralPath $path).Length
|
||||
sha256 = Get-ShadowSha256 $path
|
||||
}
|
||||
})
|
||||
$manifest = [pscustomobject]@{
|
||||
schemaVersion = 1
|
||||
runId = $RunId.ToString()
|
||||
exportedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
sourceMode = $PSCmdlet.ParameterSetName
|
||||
files = $files
|
||||
}
|
||||
$manifestPath = Join-Path $output 'manifest.json'
|
||||
Write-ShadowJson -Value $manifest -Path $manifestPath
|
||||
$manifestHash = Get-ShadowSha256 $manifestPath
|
||||
"$manifestHash manifest.json" | Set-Content -LiteralPath `
|
||||
(Join-Path $output 'manifest.sha256') -Encoding ASCII
|
||||
return $manifest
|
||||
157
tools/shadow/ShadowScriptCommon.ps1
Normal file
157
tools/shadow/ShadowScriptCommon.ps1
Normal file
@@ -0,0 +1,157 @@
|
||||
function Resolve-ShadowFile {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
|
||||
if ($item.PSIsContainer) { throw "FILE_REQUIRED:$Path" }
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Resolve-ShadowDirectory {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[switch]$Create
|
||||
)
|
||||
if ($Create -and -not (Test-Path -LiteralPath $Path)) {
|
||||
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||
}
|
||||
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
|
||||
if (-not $item.PSIsContainer) { throw "DIRECTORY_REQUIRED:$Path" }
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Write-ShadowJson {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Value,
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[int]$Depth = 12
|
||||
)
|
||||
$parent = Split-Path -Parent ([IO.Path]::GetFullPath($Path))
|
||||
if (-not (Test-Path -LiteralPath $parent)) {
|
||||
New-Item -ItemType Directory -Path $parent -Force | Out-Null
|
||||
}
|
||||
$Value | ConvertTo-Json -Depth $Depth | Set-Content -LiteralPath $Path `
|
||||
-Encoding UTF8
|
||||
}
|
||||
|
||||
function Read-ShadowJson {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
$resolved = Resolve-ShadowFile $Path
|
||||
return Get-Content -LiteralPath $resolved -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-ShadowSha256 {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
return (Get-FileHash -LiteralPath (Resolve-ShadowFile $Path) `
|
||||
-Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Get-ShadowCommandLine {
|
||||
param([Parameter(Mandatory = $true)][int]$ProcessId)
|
||||
try {
|
||||
$row = Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId" `
|
||||
-ErrorAction Stop
|
||||
return $row.CommandLine
|
||||
}
|
||||
catch { return $null }
|
||||
}
|
||||
|
||||
function Get-ShadowListeningPorts {
|
||||
param([Parameter(Mandatory = $true)][int]$ProcessId)
|
||||
try {
|
||||
return @(Get-NetTCPConnection -OwningProcess $ProcessId -State Listen `
|
||||
-ErrorAction Stop | Sort-Object LocalPort | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
address = $_.LocalAddress
|
||||
port = [int]$_.LocalPort
|
||||
state = $_.State.ToString()
|
||||
}
|
||||
})
|
||||
}
|
||||
catch { return @() }
|
||||
}
|
||||
|
||||
function Test-ShadowPathWithin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Candidate,
|
||||
[Parameter(Mandatory = $true)][string]$Directory
|
||||
)
|
||||
$candidatePath = [IO.Path]::GetFullPath($Candidate)
|
||||
$directoryPath = [IO.Path]::GetFullPath($Directory).TrimEnd('\', '/')
|
||||
if ($candidatePath.Equals($directoryPath,
|
||||
[StringComparison]::OrdinalIgnoreCase)) { return $true }
|
||||
$prefix = $directoryPath + [IO.Path]::DirectorySeparatorChar
|
||||
return $candidatePath.StartsWith($prefix,
|
||||
[StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Get-ShadowFreeBytes {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
$root = [IO.Path]::GetPathRoot([IO.Path]::GetFullPath($Path))
|
||||
return ([IO.DriveInfo]::new($root)).AvailableFreeSpace
|
||||
}
|
||||
|
||||
function Resolve-ShadowScalarPlaceholder {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ($null -eq $Value) { return $null }
|
||||
if ($Value -match '^\$\{([A-Za-z_][A-Za-z0-9_]*):(.*)\}$') {
|
||||
$environmentValue = [Environment]::GetEnvironmentVariable($matches[1])
|
||||
return if ($null -eq $environmentValue) { $matches[2] } `
|
||||
else { $environmentValue }
|
||||
}
|
||||
return $Value
|
||||
}
|
||||
|
||||
function Read-ShadowYamlScalars {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
$resolved = Resolve-ShadowFile $Path
|
||||
$parents = @{}
|
||||
$values = @{}
|
||||
foreach ($line in Get-Content -LiteralPath $resolved) {
|
||||
if ($line -match '^\s*(#.*)?$') { continue }
|
||||
if ($line -notmatch '^(\s*)([A-Za-z0-9_-]+):(?:\s*(.*))?$') {
|
||||
continue
|
||||
}
|
||||
$indent = $matches[1].Length
|
||||
$key = $matches[2]
|
||||
$raw = if ($null -eq $matches[3]) { '' } else { $matches[3].Trim() }
|
||||
foreach ($level in @($parents.Keys | Where-Object { [int]$_ -ge $indent })) {
|
||||
$parents.Remove($level)
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||
$parents[$indent] = $key
|
||||
continue
|
||||
}
|
||||
$segments = @($parents.Keys | Sort-Object { [int]$_ } | ForEach-Object {
|
||||
$parents[$_]
|
||||
}) + $key
|
||||
if (($raw.StartsWith('"') -and $raw.EndsWith('"')) -or `
|
||||
($raw.StartsWith("'") -and $raw.EndsWith("'"))) {
|
||||
$raw = $raw.Substring(1, $raw.Length - 2)
|
||||
}
|
||||
$values[($segments -join '.')] = Resolve-ShadowScalarPlaceholder $raw
|
||||
}
|
||||
return $values
|
||||
}
|
||||
|
||||
function Get-ShadowSqlServerJdbcIdentity {
|
||||
param([Parameter(Mandatory = $true)][string]$JdbcUrl)
|
||||
if ($JdbcUrl -notmatch '(?i)^jdbc:sqlserver://([^;]+)(?:;(.*))?$') {
|
||||
throw 'SQLSERVER_JDBC_URL_REQUIRED'
|
||||
}
|
||||
$server = $matches[1].Trim()
|
||||
$properties = @{}
|
||||
foreach ($part in @($matches[2] -split ';')) {
|
||||
if ($part -match '^\s*([^=]+)=(.*)$') {
|
||||
$properties[$matches[1].Trim().ToLowerInvariant()] =
|
||||
$matches[2].Trim()
|
||||
}
|
||||
}
|
||||
$database = $properties['databasename']
|
||||
if ([string]::IsNullOrWhiteSpace($database)) {
|
||||
$database = $properties['database']
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($server) -or `
|
||||
[string]::IsNullOrWhiteSpace($database)) {
|
||||
throw 'SQLSERVER_JDBC_IDENTITY_INCOMPLETE'
|
||||
}
|
||||
return [pscustomobject]@{ server = $server; database = $database }
|
||||
}
|
||||
112
tools/shadow/Start-Ft546Shadow.ps1
Normal file
112
tools/shadow/Start-Ft546Shadow.ps1
Normal file
@@ -0,0 +1,112 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$ConfirmReadOnlyShadow,
|
||||
[Parameter(Mandatory = $true)][string]$PreflightJson,
|
||||
[string]$JavaExecutable = 'java',
|
||||
[int]$MaximumPreflightAgeMinutes = 30
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
|
||||
|
||||
if (-not $ConfirmReadOnlyShadow.IsPresent) {
|
||||
throw 'CONFIRM_READ_ONLY_SHADOW_REQUIRED'
|
||||
}
|
||||
$preflightPath = Resolve-ShadowFile $PreflightJson
|
||||
$preflight = Read-ShadowJson $preflightPath
|
||||
if (-not [bool]$preflight.success) { throw 'SUCCESSFUL_PREFLIGHT_REQUIRED' }
|
||||
$generatedAt = [DateTime]::Parse($preflight.generatedAtUtc).ToUniversalTime()
|
||||
if (([DateTime]::UtcNow - $generatedAt).TotalMinutes -gt `
|
||||
$MaximumPreflightAgeMinutes) {
|
||||
throw 'PREFLIGHT_EXPIRED'
|
||||
}
|
||||
if ($null -eq (Get-Process -Id ([int]$preflight.stable.pid) `
|
||||
-ErrorAction SilentlyContinue)) {
|
||||
throw 'STABLE_PROCESS_NOT_HEALTHY'
|
||||
}
|
||||
|
||||
$artifact = Resolve-ShadowFile $preflight.shadowArtifact.path
|
||||
$config = Resolve-ShadowFile $preflight.config.path
|
||||
$deployment = Resolve-ShadowDirectory $preflight.deploymentDirectory
|
||||
if (-not (Test-ShadowPathWithin $artifact $deployment) -or `
|
||||
-not (Test-ShadowPathWithin $config $deployment)) {
|
||||
throw 'SHADOW_FILES_MUST_BE_INSIDE_DEPLOYMENT_DIRECTORY'
|
||||
}
|
||||
if ((Get-ShadowSha256 $artifact) -ne $preflight.shadowArtifact.sha256) {
|
||||
throw 'SHADOW_ARTIFACT_CHANGED_AFTER_PREFLIGHT'
|
||||
}
|
||||
if ((Get-ShadowSha256 $config) -ne $preflight.config.sha256) {
|
||||
throw 'SHADOW_CONFIG_CHANGED_AFTER_PREFLIGHT'
|
||||
}
|
||||
if ((Get-ShadowSha256 $preflight.permissionReport.path) -ne `
|
||||
$preflight.permissionReport.sha256) {
|
||||
throw 'PERMISSION_REPORT_CHANGED_AFTER_PREFLIGHT'
|
||||
}
|
||||
if ((Get-ShadowSha256 $preflight.networkDenyReport.path) -ne `
|
||||
$preflight.networkDenyReport.sha256) {
|
||||
throw 'NETWORK_DENY_REPORT_CHANGED_AFTER_PREFLIGHT'
|
||||
}
|
||||
|
||||
$pidFile = [IO.Path]::GetFullPath($preflight.pidFile)
|
||||
if (-not (Test-ShadowPathWithin $pidFile $deployment)) {
|
||||
throw 'PID_FILE_MUST_BE_INSIDE_DEPLOYMENT_DIRECTORY'
|
||||
}
|
||||
if (Test-Path -LiteralPath $pidFile) { throw 'SHADOW_PID_FILE_ALREADY_EXISTS' }
|
||||
$java = (Get-Command $JavaExecutable -ErrorAction Stop).Source
|
||||
$logs = Join-Path $deployment 'logs'
|
||||
New-Item -ItemType Directory -Path $logs -Force | Out-Null
|
||||
$stdout = Join-Path $logs 'shadow-stdout.log'
|
||||
$stderr = Join-Path $logs 'shadow-stderr.log'
|
||||
$configUri = ([uri]$config).AbsoluteUri
|
||||
$statusUri = [uri]$preflight.statusUri
|
||||
$runtimeProperties = @(
|
||||
'--spring.profiles.active=shadow',
|
||||
'--shadow.enabled=true',
|
||||
"--spring.config.additional-location=$configUri",
|
||||
"--server.address=$($statusUri.Host)",
|
||||
"--server.port=$($statusUri.Port)",
|
||||
"--shadow.evidence.run.artifact-sha256=$($preflight.shadowArtifact.sha256)",
|
||||
"--shadow.evidence.run.configuration-sha256=$($preflight.config.sha256)",
|
||||
"--shadow.evidence.run.source-server=$($preflight.sourceIdentity.server)",
|
||||
"--shadow.evidence.run.source-database=$($preflight.sourceIdentity.database)",
|
||||
"--shadow.evidence.minimum-free-bytes=$($preflight.minimumFreeBytes)"
|
||||
)
|
||||
$quotedProperties = @($runtimeProperties | ForEach-Object {
|
||||
'"{0}"' -f $_.Replace('"', '\"')
|
||||
}) -join ' '
|
||||
$arguments = '-jar "{0}" {1}' -f $artifact.Replace('"', '\"'), `
|
||||
$quotedProperties
|
||||
|
||||
$process = $null
|
||||
try {
|
||||
$process = Start-Process -FilePath $java -ArgumentList $arguments `
|
||||
-WorkingDirectory $deployment -WindowStyle Hidden -PassThru `
|
||||
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
||||
Start-Sleep -Milliseconds 300
|
||||
if ($process.HasExited) {
|
||||
throw "SHADOW_PROCESS_EXITED_EARLY:$($process.ExitCode)"
|
||||
}
|
||||
$record = [pscustomobject]@{
|
||||
schemaVersion = 1
|
||||
pid = [int]$process.Id
|
||||
processStartTimeUtc = $process.StartTime.ToUniversalTime().ToString('o')
|
||||
javaExecutable = [IO.Path]::GetFullPath($java)
|
||||
shadowArtifact = $artifact
|
||||
shadowArtifactSha256 = Get-ShadowSha256 $artifact
|
||||
config = $config
|
||||
configSha256 = Get-ShadowSha256 $config
|
||||
deploymentDirectory = $deployment
|
||||
preflightJson = $preflightPath
|
||||
preflightSha256 = Get-ShadowSha256 $preflightPath
|
||||
startedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
}
|
||||
Write-ShadowJson -Value $record -Path $pidFile
|
||||
return $record
|
||||
}
|
||||
catch {
|
||||
if ($null -ne $process -and -not $process.HasExited) {
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
throw
|
||||
}
|
||||
58
tools/shadow/Stop-Ft546Shadow.ps1
Normal file
58
tools/shadow/Stop-Ft546Shadow.ps1
Normal file
@@ -0,0 +1,58 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PidFile,
|
||||
[int]$WaitSeconds = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
|
||||
|
||||
$pidPath = Resolve-ShadowFile $PidFile
|
||||
$record = Read-ShadowJson $pidPath
|
||||
$deployment = Resolve-ShadowDirectory $record.deploymentDirectory
|
||||
if (-not (Test-ShadowPathWithin $pidPath $deployment)) {
|
||||
throw 'PID_FILE_OUTSIDE_RECORDED_DEPLOYMENT'
|
||||
}
|
||||
$artifact = Resolve-ShadowFile $record.shadowArtifact
|
||||
if ((Get-ShadowSha256 $artifact) -ne $record.shadowArtifactSha256) {
|
||||
throw 'RECORDED_SHADOW_ARTIFACT_HASH_MISMATCH'
|
||||
}
|
||||
$process = Get-Process -Id ([int]$record.pid) -ErrorAction Stop
|
||||
$actualExecutable = [IO.Path]::GetFullPath($process.Path)
|
||||
$recordedExecutable = [IO.Path]::GetFullPath($record.javaExecutable)
|
||||
if (-not $actualExecutable.Equals($recordedExecutable,
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'RECORDED_PROCESS_EXECUTABLE_MISMATCH'
|
||||
}
|
||||
$recordedStart = [DateTime]::Parse($record.processStartTimeUtc).ToUniversalTime()
|
||||
$actualStart = $process.StartTime.ToUniversalTime()
|
||||
if ([Math]::Abs(($actualStart - $recordedStart).TotalSeconds) -gt 1) {
|
||||
throw 'RECORDED_PROCESS_START_TIME_MISMATCH'
|
||||
}
|
||||
$commandLine = Get-ShadowCommandLine $process.Id
|
||||
if ([string]::IsNullOrWhiteSpace($commandLine) -or `
|
||||
$commandLine -notlike '*-jar*' -or `
|
||||
($commandLine -notlike "*$artifact*" -and `
|
||||
$commandLine -notlike "*$([IO.Path]::GetFileName($artifact))*")) {
|
||||
throw 'RECORDED_PROCESS_COMMAND_MISMATCH'
|
||||
}
|
||||
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction Stop
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds($WaitSeconds)
|
||||
while ([DateTime]::UtcNow -lt $deadline -and `
|
||||
$null -ne (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) {
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
if ($null -ne (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) {
|
||||
throw 'SHADOW_PROCESS_DID_NOT_STOP'
|
||||
}
|
||||
$archive = Join-Path $deployment `
|
||||
("shadow.stopped.{0}.json" -f [DateTime]::UtcNow.ToString('yyyyMMddHHmmssfff'))
|
||||
Move-Item -LiteralPath $pidPath -Destination $archive
|
||||
return [pscustomobject]@{
|
||||
status = 'STOPPED'
|
||||
pid = [int]$record.pid
|
||||
stoppedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
archivedPidRecord = $archive
|
||||
}
|
||||
194
tools/shadow/Test-Ft546ShadowPreflight.ps1
Normal file
194
tools/shadow/Test-Ft546ShadowPreflight.ps1
Normal file
@@ -0,0 +1,194 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$StableManifest,
|
||||
[Parameter(Mandatory = $true)][string]$ShadowArtifact,
|
||||
[Parameter(Mandatory = $true)][string]$Config,
|
||||
[Parameter(Mandatory = $true)][string]$DeploymentDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$PermissionReport,
|
||||
[Parameter(Mandatory = $true)][string]$NetworkDenyReport,
|
||||
[uri]$StatusUri = 'http://127.0.0.1:18091/shadow/status',
|
||||
[long]$MinimumFreeBytes = 1073741824,
|
||||
[int]$MaximumEvidenceAgeMinutes = 60,
|
||||
[Parameter(Mandatory = $true)][string]$OutputJson
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
. (Join-Path $PSScriptRoot 'ShadowScriptCommon.ps1')
|
||||
|
||||
$checks = New-Object System.Collections.ArrayList
|
||||
function Add-PreflightCheck {
|
||||
param([string]$Name, [bool]$Passed, [string]$Detail)
|
||||
[void]$checks.Add([pscustomobject]@{
|
||||
name = $Name
|
||||
status = if ($Passed) { 'PASS' } else { 'FAIL' }
|
||||
detail = $Detail
|
||||
})
|
||||
}
|
||||
|
||||
$stablePath = Resolve-ShadowFile $StableManifest
|
||||
$stable = Read-ShadowJson $stablePath
|
||||
$artifact = Resolve-ShadowFile $ShadowArtifact
|
||||
$configPath = Resolve-ShadowFile $Config
|
||||
$deployment = Resolve-ShadowDirectory $DeploymentDirectory -Create
|
||||
$permissions = Read-ShadowJson $PermissionReport
|
||||
$network = Read-ShadowJson $NetworkDenyReport
|
||||
$configValues = Read-ShadowYamlScalars $configPath
|
||||
|
||||
$stableAlive = $null -ne (Get-Process -Id ([int]$stable.pid) `
|
||||
-ErrorAction SilentlyContinue)
|
||||
Add-PreflightCheck 'STABLE_PROCESS_HEALTHY' $stableAlive `
|
||||
"pid=$($stable.pid)"
|
||||
$stableArtifactExists = Test-Path -LiteralPath $stable.artifactPath -PathType Leaf
|
||||
$stableHashMatches = $stableArtifactExists -and `
|
||||
((Get-ShadowSha256 $stable.artifactPath) -eq $stable.artifactSha256)
|
||||
Add-PreflightCheck 'STABLE_ARTIFACT_HASH_UNCHANGED' $stableHashMatches `
|
||||
$stable.artifactPath
|
||||
$stableConfigMatches = $true
|
||||
foreach ($stableConfig in @($stable.configuration)) {
|
||||
$stableConfigMatches = $stableConfigMatches -and `
|
||||
(Test-Path -LiteralPath $stableConfig.path -PathType Leaf) -and `
|
||||
((Get-ShadowSha256 $stableConfig.path) -eq $stableConfig.sha256)
|
||||
}
|
||||
Add-PreflightCheck 'STABLE_CONFIG_HASHES_UNCHANGED' $stableConfigMatches `
|
||||
"count=$(@($stable.configuration).Count)"
|
||||
|
||||
$artifactHash = Get-ShadowSha256 $artifact
|
||||
$configHash = Get-ShadowSha256 $configPath
|
||||
$artifactInDeployment = Test-ShadowPathWithin $artifact $deployment
|
||||
$configInDeployment = Test-ShadowPathWithin $configPath $deployment
|
||||
Add-PreflightCheck 'SHADOW_ARTIFACT_IN_DEPLOYMENT' $artifactInDeployment $artifact
|
||||
Add-PreflightCheck 'SHADOW_CONFIG_IN_DEPLOYMENT' $configInDeployment $configPath
|
||||
|
||||
$stableDirectory = Split-Path -Parent $stable.artifactPath
|
||||
$deploymentDistinct = -not ([IO.Path]::GetFullPath($deployment).Equals(
|
||||
[IO.Path]::GetFullPath($stableDirectory),
|
||||
[StringComparison]::OrdinalIgnoreCase))
|
||||
Add-PreflightCheck 'DEPLOYMENT_DISTINCT_FROM_STABLE' $deploymentDistinct `
|
||||
"stable=$stableDirectory;shadow=$deployment"
|
||||
|
||||
$permissionPassed = $permissions.status -eq 'PASS' -and `
|
||||
[bool]$permissions.source.approvedViewsReadable -and `
|
||||
([int]$permissions.source.productionWritePermissionCount -eq 0) -and `
|
||||
[bool]$permissions.evidence.requiredWritesGranted
|
||||
Add-PreflightCheck 'DATABASE_PERMISSIONS_APPROVED' $permissionPassed `
|
||||
"source=$($permissions.source.login);evidence=$($permissions.evidence.login)"
|
||||
$permissionFresh = $false
|
||||
try {
|
||||
$permissionAge = ([DateTime]::UtcNow -
|
||||
[DateTime]::Parse($permissions.generatedAtUtc).ToUniversalTime()).TotalMinutes
|
||||
$permissionFresh = $permissionAge -ge 0 -and `
|
||||
$permissionAge -le $MaximumEvidenceAgeMinutes
|
||||
}
|
||||
catch { $permissionFresh = $false }
|
||||
Add-PreflightCheck 'DATABASE_PERMISSION_REPORT_FRESH' $permissionFresh `
|
||||
"maximumMinutes=$MaximumEvidenceAgeMinutes"
|
||||
|
||||
$configSource = $null
|
||||
$configEvidence = $null
|
||||
$configIdentityError = $null
|
||||
try {
|
||||
$configSource = Get-ShadowSqlServerJdbcIdentity `
|
||||
$configValues['shadow.source.jdbc-url']
|
||||
$configEvidence = Get-ShadowSqlServerJdbcIdentity `
|
||||
$configValues['shadow.evidence.jdbc-url']
|
||||
}
|
||||
catch { $configIdentityError = $_.Exception.Message }
|
||||
$sourceConfigMatches = $null -ne $configSource -and `
|
||||
$configSource.server.Equals([string]$permissions.source.server,
|
||||
[StringComparison]::OrdinalIgnoreCase) -and `
|
||||
$configSource.database.Equals([string]$permissions.source.database,
|
||||
[StringComparison]::OrdinalIgnoreCase) -and `
|
||||
([string]$configValues['shadow.source.username']).Equals(
|
||||
[string]$permissions.source.login,
|
||||
[StringComparison]::OrdinalIgnoreCase)
|
||||
Add-PreflightCheck 'CONFIG_SOURCE_IDENTITY_MATCHES_PERMISSION_REPORT' `
|
||||
$sourceConfigMatches $(if ($configIdentityError) { $configIdentityError } `
|
||||
else { "config=$($configSource.server)|$($configSource.database)" })
|
||||
$evidenceConfigMatches = $null -ne $configEvidence -and `
|
||||
$configEvidence.server.Equals([string]$permissions.evidence.server,
|
||||
[StringComparison]::OrdinalIgnoreCase) -and `
|
||||
$configEvidence.database.Equals([string]$permissions.evidence.database,
|
||||
[StringComparison]::OrdinalIgnoreCase) -and `
|
||||
([string]$configValues['shadow.evidence.username']).Equals(
|
||||
[string]$permissions.evidence.login,
|
||||
[StringComparison]::OrdinalIgnoreCase)
|
||||
Add-PreflightCheck 'CONFIG_EVIDENCE_IDENTITY_MATCHES_PERMISSION_REPORT' `
|
||||
$evidenceConfigMatches $(if ($configIdentityError) { $configIdentityError } `
|
||||
else { "config=$($configEvidence.server)|$($configEvidence.database)" })
|
||||
$sourceIdentity = ("{0}|{1}" -f $permissions.source.server,
|
||||
$permissions.source.database).ToLowerInvariant()
|
||||
$evidenceIdentity = ("{0}|{1}" -f $permissions.evidence.server,
|
||||
$permissions.evidence.database).ToLowerInvariant()
|
||||
$databasesDistinct = $sourceIdentity -ne $evidenceIdentity
|
||||
Add-PreflightCheck 'SOURCE_EVIDENCE_DATABASES_DISTINCT' $databasesDistinct `
|
||||
"source=$sourceIdentity;evidence=$evidenceIdentity"
|
||||
|
||||
$networkPassed = $network.status -eq 'PASS' -and `
|
||||
[bool]$network.plcEgressDenied -and [bool]$network.wmsEgressDenied
|
||||
Add-PreflightCheck 'PLC_WMS_EGRESS_DENIED' $networkPassed `
|
||||
"plc=$($network.plcEgressDenied);wms=$($network.wmsEgressDenied)"
|
||||
$networkFresh = $false
|
||||
try {
|
||||
$networkAge = ([DateTime]::UtcNow -
|
||||
[DateTime]::Parse($network.generatedAtUtc).ToUniversalTime()).TotalMinutes
|
||||
$networkFresh = $networkAge -ge 0 -and `
|
||||
$networkAge -le $MaximumEvidenceAgeMinutes
|
||||
}
|
||||
catch { $networkFresh = $false }
|
||||
Add-PreflightCheck 'NETWORK_DENY_REPORT_FRESH' $networkFresh `
|
||||
"maximumMinutes=$MaximumEvidenceAgeMinutes"
|
||||
|
||||
$loopback = $StatusUri.Host -in @('127.0.0.1', 'localhost', '::1')
|
||||
Add-PreflightCheck 'STATUS_ENDPOINT_LOOPBACK' $loopback $StatusUri.AbsoluteUri
|
||||
$stablePorts = @($stable.listeningPorts | ForEach-Object { [int]$_.port })
|
||||
$portDistinct = $stablePorts -notcontains [int]$StatusUri.Port
|
||||
Add-PreflightCheck 'SHADOW_PORT_DISTINCT_FROM_STABLE' $portDistinct `
|
||||
"shadow=$($StatusUri.Port);stable=$($stablePorts -join ',')"
|
||||
|
||||
$configText = Get-Content -LiteralPath $configPath -Raw
|
||||
$outboundKeyPattern = '(?im)^\s*[^#\r\n]*(plc|wms)[^:\r\n]*(ip|host|port|url)\s*:'
|
||||
$noOutboundEndpoints = -not [regex]::IsMatch($configText, $outboundKeyPattern)
|
||||
Add-PreflightCheck 'NO_PLC_WMS_ENDPOINT_IN_CONFIG' $noOutboundEndpoints $configPath
|
||||
|
||||
$freeBytes = Get-ShadowFreeBytes $deployment
|
||||
$spaceAvailable = $MinimumFreeBytes -ge 0 -and $freeBytes -ge $MinimumFreeBytes
|
||||
Add-PreflightCheck 'OUTPUT_FREE_SPACE' $spaceAvailable `
|
||||
"available=$freeBytes;required=$MinimumFreeBytes"
|
||||
|
||||
$pidFile = Join-Path $deployment 'shadow.pid.json'
|
||||
$noShadowPidFile = -not (Test-Path -LiteralPath $pidFile)
|
||||
Add-PreflightCheck 'NO_EXISTING_SHADOW_PID_FILE' $noShadowPidFile $pidFile
|
||||
|
||||
$failed = @($checks | Where-Object { $_.status -ne 'PASS' })
|
||||
$result = [pscustomobject]@{
|
||||
schemaVersion = 1
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
success = $failed.Count -eq 0
|
||||
checks = @($checks)
|
||||
stable = $stable
|
||||
shadowArtifact = [pscustomobject]@{ path = $artifact; sha256 = $artifactHash }
|
||||
config = [pscustomobject]@{ path = $configPath; sha256 = $configHash }
|
||||
deploymentDirectory = $deployment
|
||||
pidFile = $pidFile
|
||||
statusUri = $StatusUri.AbsoluteUri
|
||||
sourceIdentity = $permissions.source
|
||||
evidenceIdentity = $permissions.evidence
|
||||
configuredSourceIdentity = $configSource
|
||||
configuredEvidenceIdentity = $configEvidence
|
||||
minimumFreeBytes = $MinimumFreeBytes
|
||||
permissionReport = [pscustomobject]@{
|
||||
path = Resolve-ShadowFile $PermissionReport
|
||||
sha256 = Get-ShadowSha256 $PermissionReport
|
||||
}
|
||||
networkDenyReport = [pscustomobject]@{
|
||||
path = Resolve-ShadowFile $NetworkDenyReport
|
||||
sha256 = Get-ShadowSha256 $NetworkDenyReport
|
||||
}
|
||||
freeBytes = $freeBytes
|
||||
}
|
||||
Write-ShadowJson -Value $result -Path $OutputJson
|
||||
if ($failed.Count -gt 0) {
|
||||
throw "SHADOW_PREFLIGHT_FAILED:$($failed.name -join ',')"
|
||||
}
|
||||
return $result
|
||||
254
tools/shadow/tests/Test-Ft546ShadowScripts.ps1
Normal file
254
tools/shadow/tests/Test-Ft546ShadowScripts.ps1
Normal file
@@ -0,0 +1,254 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Assert-True {
|
||||
param([bool]$Condition, [string]$Message)
|
||||
if (-not $Condition) { throw "ASSERTION_FAILED: $Message" }
|
||||
}
|
||||
|
||||
function Assert-Throws {
|
||||
param([scriptblock]$Action, [string]$Reason)
|
||||
try {
|
||||
& $Action
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message -like "*$Reason*") { return }
|
||||
throw "EXPECTED_$Reason, ACTUAL=$($_.Exception.Message)"
|
||||
}
|
||||
throw "EXPECTED_$Reason, BUT_COMMAND_SUCCEEDED"
|
||||
}
|
||||
|
||||
$toolsDirectory = Split-Path -Parent $PSScriptRoot
|
||||
$capture = Join-Path $toolsDirectory 'Capture-StableWcsManifest.ps1'
|
||||
$preflight = Join-Path $toolsDirectory 'Test-Ft546ShadowPreflight.ps1'
|
||||
$start = Join-Path $toolsDirectory 'Start-Ft546Shadow.ps1'
|
||||
$stop = Join-Path $toolsDirectory 'Stop-Ft546Shadow.ps1'
|
||||
$export = Join-Path $toolsDirectory 'Export-Ft546ShadowEvidence.ps1'
|
||||
|
||||
foreach ($script in @($capture, $preflight, $start, $stop, $export)) {
|
||||
Assert-True (Test-Path -LiteralPath $script -PathType Leaf) `
|
||||
"missing script $script"
|
||||
}
|
||||
|
||||
$testRoot = Join-Path ([IO.Path]::GetTempPath()) `
|
||||
("ft546-shadow-script-test-" + [guid]::NewGuid().ToString('N'))
|
||||
$deployment = Join-Path $testRoot 'shadow-deployment'
|
||||
$stableDirectory = Join-Path $testRoot 'stable-deployment'
|
||||
$snapshot = Join-Path $testRoot 'evidence-snapshot'
|
||||
$exportDirectory = Join-Path $testRoot 'evidence-export'
|
||||
$startedPid = $null
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $deployment, $stableDirectory, $snapshot `
|
||||
-Force | Out-Null
|
||||
|
||||
$sourceFile = Join-Path $testRoot 'ShadowSleeper.java'
|
||||
@'
|
||||
public final class ShadowSleeper {
|
||||
public static void main(String[] args) throws Exception {
|
||||
Thread.sleep(300000L);
|
||||
}
|
||||
}
|
||||
'@ | Set-Content -LiteralPath $sourceFile -Encoding ASCII
|
||||
& javac -d $testRoot $sourceFile
|
||||
if ($LASTEXITCODE -ne 0) { throw 'JAVAC_FAILED' }
|
||||
$shadowArtifact = Join-Path $deployment 'ruoyi-wcs-shadow.jar'
|
||||
& jar cfe $shadowArtifact ShadowSleeper -C $testRoot 'ShadowSleeper.class'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'JAR_FAILED' }
|
||||
$stableArtifact = Join-Path $stableDirectory 'ruoyi-admin.jar'
|
||||
Copy-Item -LiteralPath $shadowArtifact -Destination $stableArtifact
|
||||
|
||||
$stableConfig = Join-Path $stableDirectory 'application.yml'
|
||||
'spring.profiles.active: stable' | Set-Content -LiteralPath $stableConfig `
|
||||
-Encoding ASCII
|
||||
$shadowConfig = Join-Path $deployment 'application-shadow-site.yml'
|
||||
@'
|
||||
server:
|
||||
address: 127.0.0.1
|
||||
port: 18091
|
||||
shadow:
|
||||
enabled: true
|
||||
source:
|
||||
jdbc-url: jdbc:sqlserver://source.example.invalid;databaseName=FT546-wcs
|
||||
username: shadow_reader
|
||||
evidence:
|
||||
jdbc-url: jdbc:sqlserver://evidence.example.invalid;databaseName=FT546-shadow
|
||||
username: shadow_writer
|
||||
'@ | Set-Content -LiteralPath $shadowConfig -Encoding ASCII
|
||||
|
||||
$stableManifest = Join-Path $testRoot 'stable-manifest.json'
|
||||
& $capture -StableArtifact $stableArtifact -StablePid $PID `
|
||||
-Configuration $stableConfig -OutputJson $stableManifest | Out-Null
|
||||
$stable = Get-Content -LiteralPath $stableManifest -Raw | ConvertFrom-Json
|
||||
Assert-True ($stable.status -eq 'PASS') 'stable manifest must pass'
|
||||
Assert-True ($stable.pid -eq $PID) 'stable PID must be captured exactly'
|
||||
|
||||
$permissionReport = Join-Path $testRoot 'permission-report.json'
|
||||
[ordered]@{
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
status = 'PASS'
|
||||
source = [ordered]@{
|
||||
server = 'source.example.invalid'; database = 'FT546-wcs'
|
||||
login = 'shadow_reader'; approvedViewsReadable = $true
|
||||
productionWritePermissionCount = 0
|
||||
}
|
||||
evidence = [ordered]@{
|
||||
server = 'evidence.example.invalid'; database = 'FT546-shadow'
|
||||
login = 'shadow_writer'; requiredWritesGranted = $true
|
||||
}
|
||||
} | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $permissionReport `
|
||||
-Encoding UTF8
|
||||
$networkReport = Join-Path $testRoot 'network-deny-report.json'
|
||||
[ordered]@{
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
status = 'PASS'; plcEgressDenied = $true; wmsEgressDenied = $true
|
||||
} | ConvertTo-Json | Set-Content -LiteralPath $networkReport -Encoding UTF8
|
||||
|
||||
$wrongPermissionReport = Join-Path $testRoot 'wrong-permission-report.json'
|
||||
$wrongPermissions = Get-Content -LiteralPath $permissionReport -Raw | `
|
||||
ConvertFrom-Json
|
||||
$wrongPermissions.source.database = 'WRONG_DATABASE'
|
||||
$wrongPermissions | ConvertTo-Json -Depth 5 | Set-Content `
|
||||
-LiteralPath $wrongPermissionReport -Encoding UTF8
|
||||
Assert-Throws {
|
||||
& $preflight -StableManifest $stableManifest `
|
||||
-ShadowArtifact $shadowArtifact -Config $shadowConfig `
|
||||
-DeploymentDirectory $deployment `
|
||||
-PermissionReport $wrongPermissionReport `
|
||||
-NetworkDenyReport $networkReport `
|
||||
-StatusUri 'http://127.0.0.1:18091/shadow/status' `
|
||||
-MinimumFreeBytes 0 `
|
||||
-OutputJson (Join-Path $testRoot 'wrong-preflight.json') | Out-Null
|
||||
} 'CONFIG_SOURCE_IDENTITY_MATCHES_PERMISSION_REPORT'
|
||||
|
||||
$stalePidFile = Join-Path $deployment 'shadow.pid.json'
|
||||
[ordered]@{ pid = 2147483000 } | ConvertTo-Json | Set-Content `
|
||||
-LiteralPath $stalePidFile -Encoding UTF8
|
||||
Assert-Throws {
|
||||
& $preflight -StableManifest $stableManifest `
|
||||
-ShadowArtifact $shadowArtifact -Config $shadowConfig `
|
||||
-DeploymentDirectory $deployment `
|
||||
-PermissionReport $permissionReport `
|
||||
-NetworkDenyReport $networkReport `
|
||||
-StatusUri 'http://127.0.0.1:18091/shadow/status' `
|
||||
-MinimumFreeBytes 0 `
|
||||
-OutputJson (Join-Path $testRoot 'stale-pid-preflight.json') | Out-Null
|
||||
} 'NO_EXISTING_SHADOW_PID_FILE'
|
||||
Remove-Item -LiteralPath $stalePidFile
|
||||
|
||||
$preflightJson = Join-Path $testRoot 'shadow-preflight.json'
|
||||
& $preflight -StableManifest $stableManifest `
|
||||
-ShadowArtifact $shadowArtifact -Config $shadowConfig `
|
||||
-DeploymentDirectory $deployment -PermissionReport $permissionReport `
|
||||
-NetworkDenyReport $networkReport -StatusUri 'http://127.0.0.1:18091/shadow/status' `
|
||||
-MinimumFreeBytes 0 -OutputJson $preflightJson | Out-Null
|
||||
$preflightResult = Get-Content -LiteralPath $preflightJson -Raw | ConvertFrom-Json
|
||||
Assert-True ([bool]$preflightResult.success) 'preflight must pass'
|
||||
Assert-True ($preflightResult.stable.pid -eq $PID) `
|
||||
'preflight must preserve stable PID'
|
||||
|
||||
Assert-Throws {
|
||||
& $start -PreflightJson $preflightJson | Out-Null
|
||||
} 'CONFIRM_READ_ONLY_SHADOW_REQUIRED'
|
||||
|
||||
$configBytes = [IO.File]::ReadAllBytes($shadowConfig)
|
||||
Add-Content -LiteralPath $shadowConfig -Value '# changed after preflight' `
|
||||
-Encoding ASCII
|
||||
Assert-Throws {
|
||||
& $start -ConfirmReadOnlyShadow -PreflightJson $preflightJson | Out-Null
|
||||
} 'SHADOW_CONFIG_CHANGED_AFTER_PREFLIGHT'
|
||||
[IO.File]::WriteAllBytes($shadowConfig, $configBytes)
|
||||
|
||||
$startResult = & $start -ConfirmReadOnlyShadow `
|
||||
-PreflightJson $preflightJson
|
||||
$startedPid = [int]$startResult.pid
|
||||
Assert-True ($startedPid -ne $PID) 'shadow PID must differ from stable PID'
|
||||
Assert-True ($null -ne (Get-Process -Id $startedPid -ErrorAction Stop)) `
|
||||
'shadow process must be alive after start'
|
||||
Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) `
|
||||
'stable process must remain alive after shadow start'
|
||||
$shadowCommandLine = (Get-CimInstance Win32_Process `
|
||||
-Filter "ProcessId=$startedPid").CommandLine
|
||||
Assert-True ($shadowCommandLine -like `
|
||||
'*--shadow.evidence.run.artifact-sha256=*') `
|
||||
'start must pass the verified artifact hash'
|
||||
Assert-True ($shadowCommandLine -like `
|
||||
'*--shadow.evidence.run.configuration-sha256=*') `
|
||||
'start must pass the verified configuration hash'
|
||||
Assert-True ($shadowCommandLine -like `
|
||||
'*--shadow.evidence.run.source-database=FT546-wcs*') `
|
||||
'start must pass the verified source database'
|
||||
Assert-True ($shadowCommandLine -like '*--server.address=127.0.0.1*') `
|
||||
'start must force the preflight loopback address'
|
||||
Assert-True ($shadowCommandLine -like '*--server.port=18091*') `
|
||||
'start must force the preflight status port'
|
||||
|
||||
$pidFile = Join-Path $deployment 'shadow.pid.json'
|
||||
& $stop -PidFile $pidFile | Out-Null
|
||||
Assert-True ($null -eq (Get-Process -Id $startedPid -ErrorAction SilentlyContinue)) `
|
||||
'recorded shadow PID must stop'
|
||||
$startedPid = $null
|
||||
Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) `
|
||||
'stable process must remain alive after shadow stop'
|
||||
|
||||
$stableProcess = Get-Process -Id $PID
|
||||
[ordered]@{
|
||||
pid = $PID
|
||||
processStartTimeUtc = $stableProcess.StartTime.ToUniversalTime().ToString('o')
|
||||
javaExecutable = $stableProcess.Path
|
||||
shadowArtifact = $shadowArtifact
|
||||
shadowArtifactSha256 = (Get-FileHash -LiteralPath $shadowArtifact -Algorithm SHA256).Hash
|
||||
deploymentDirectory = $deployment
|
||||
} | ConvertTo-Json | Set-Content -LiteralPath $pidFile -Encoding UTF8
|
||||
Assert-Throws { & $stop -PidFile $pidFile | Out-Null } `
|
||||
'RECORDED_PROCESS_COMMAND_MISMATCH'
|
||||
Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) `
|
||||
'mismatched PID must never be stopped'
|
||||
|
||||
$requiredEvidence = @(
|
||||
'cycles.csv', 'cycles.json', 'protocol-differences.csv',
|
||||
'algorithm-differences.csv', 'insufficient-facts.csv',
|
||||
'startup-checks.json'
|
||||
)
|
||||
foreach ($name in $requiredEvidence) {
|
||||
"fixture:$name" | Set-Content -LiteralPath (Join-Path $snapshot $name) `
|
||||
-Encoding UTF8
|
||||
}
|
||||
& $export -SnapshotDirectory $snapshot -OutputDirectory $exportDirectory `
|
||||
-RunId ([guid]::NewGuid()) | Out-Null
|
||||
Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory 'manifest.json')) `
|
||||
'evidence manifest must exist'
|
||||
Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory 'manifest.sha256')) `
|
||||
'evidence manifest hash must exist'
|
||||
$expectedManifestHash = ((Get-Content -LiteralPath `
|
||||
(Join-Path $exportDirectory 'manifest.sha256')) -split '\s+')[0]
|
||||
$actualManifestHash = (Get-FileHash -LiteralPath `
|
||||
(Join-Path $exportDirectory 'manifest.json') -Algorithm SHA256).Hash
|
||||
Assert-True ($expectedManifestHash -eq $actualManifestHash.ToLowerInvariant()) `
|
||||
'evidence manifest hash must verify'
|
||||
foreach ($name in $requiredEvidence) {
|
||||
Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory $name)) `
|
||||
"evidence export missing $name"
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
status = 'PASS'
|
||||
stablePid = $PID
|
||||
preflight = $preflightJson
|
||||
exportManifest = (Join-Path $exportDirectory 'manifest.json')
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $startedPid) {
|
||||
Stop-Process -Id $startedPid -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$resolvedTemp = [IO.Path]::GetFullPath($testRoot)
|
||||
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
if ($resolvedTemp.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) `
|
||||
-and (Test-Path -LiteralPath $resolvedTemp)) {
|
||||
Remove-Item -LiteralPath $resolvedTemp -Recurse -Force
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user