test(ft546): verify read-only shadow operation

This commit is contained in:
zgw
2026-08-14 19:08:01 +08:00
parent 240f5b0808
commit 01bdf37b06
22 changed files with 2377 additions and 6 deletions

View 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