[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