# Read-only Docker snapshot. Never persist raw Compose config, inspect or stderr. [CmdletBinding()] param( [string[]]$ComposeFile = @('deploy/compose/docker-compose.yaml'), [string]$Profile = 'microvps-full', [string]$ProjectName, [string]$OutputPath, [ValidateRange(1, 120)][int]$Samples = 1, [ValidateRange(1, 60)][int]$IntervalSeconds = 15 ) $ErrorActionPreference = 'Stop' $repoRoot = Split-Path $PSScriptRoot -Parent if (-not $OutputPath) { $OutputPath = Join-Path $repoRoot ('artifacts/backend-baseline/{0}.json' -f (Get-Date -Format 'yyyyMMdd-HHmmss-fff')) } $report = [ordered]@{ schema_version = 1 collected_at_utc = [DateTime]::UtcNow.ToString('o') status = 'unavailable' git_commit = $null profile = $Profile requested_samples = $Samples interval_seconds = $IntervalSeconds host = $null configured_services = @() containers = @() samples = @() issues = @() limitations = @('Read-only point samples, not a workload benchmark or peak-memory measurement.', 'No raw environment, commands, labels, mounts, logs or Compose configuration is stored.', 'CPU/RAM/IO measurements require a reachable Docker daemon and running project containers.') } function Invoke-DockerJSON { param([string[]]$DockerArgs, [switch]$JsonLines) # Native errors may contain expanded secrets; expose only a fixed category. $priorPreference = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $raw = & docker @DockerArgs 2>$null $code = $LASTEXITCODE } finally { $ErrorActionPreference = $priorPreference } if ($code -ne 0) { throw 'docker-command-failed' } try { if ($JsonLines) { foreach ($line in $raw) { if ("$line".Trim()) { $line | ConvertFrom-Json } } return } return (($raw -join "`n") | ConvertFrom-Json) } catch { throw 'docker-json-invalid' } } try { if (Get-Command git -ErrorAction SilentlyContinue) { $revision = & git -C $repoRoot rev-parse HEAD 2>$null if ($LASTEXITCODE -eq 0) { $report.git_commit = "$revision".Trim() } } if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw 'docker-cli-unavailable' } $composeArgs = @('compose') foreach ($file in $ComposeFile) { $absolute = if ([IO.Path]::IsPathRooted($file)) { $file } else { Join-Path $repoRoot $file } if (-not (Test-Path -LiteralPath $absolute -PathType Leaf)) { throw 'compose-file-unavailable' } $composeArgs += @('-f', $absolute) } if ($Profile) { $composeArgs += @('--profile', $Profile) } if ($ProjectName) { $composeArgs += @('--project-name', $ProjectName) } # Config is parsed only in memory. Project metadata/environment are not copied. $config = Invoke-DockerJSON -DockerArgs ($composeArgs + @('config', '--format', 'json')) foreach ($service in $config.services.PSObject.Properties) { $value = $service.Value $report.configured_services += [ordered]@{ service = $service.Name image = $value.image profiles = @($value.profiles) memory_limit = $value.deploy.resources.limits.memory cpu_limit = $value.deploy.resources.limits.cpus mem_limit = $value.mem_limit cpus = $value.cpus } } $config = $null $report.host = Invoke-DockerJSON -DockerArgs @('info', '--format', '{"server_version":{{json .ServerVersion}},"os_type":{{json .OSType}},"architecture":{{json .Architecture}},"cpus":{{json .NCPU}},"memory_bytes":{{json .MemTotal}},"cgroup_version":{{json .CgroupVersion}}}') $priorPreference = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $containerIDs = @(& docker @composeArgs ps --all --quiet 2>$null) $code = $LASTEXITCODE } finally { $ErrorActionPreference = $priorPreference } if ($code -ne 0) { throw 'compose-ps-failed' } $containerIDs = @($containerIDs | Where-Object { $_ -match '^[a-f0-9]{12,64}$' }) if ($containerIDs.Count -eq 0) { $report.issues += 'no-project-containers' } $runningIDs = @() foreach ($id in $containerIDs) { $container = Invoke-DockerJSON -DockerArgs @('inspect', '--format', '{"id":{{json .Id}},"image_id":{{json .Image}},"status":{{json .State.Status}},"running":{{json .State.Running}},"oom_killed":{{json .State.OOMKilled}},"restart_count":{{json .RestartCount}},"memory_limit_bytes":{{json .HostConfig.Memory}},"nano_cpus":{{json .HostConfig.NanoCpus}},"cpu_quota":{{json .HostConfig.CpuQuota}},"cpu_period":{{json .HostConfig.CpuPeriod}}}', $id) $report.containers += $container if ($container.running) { $runningIDs += $id } } $allSamplesComplete = $true for ($i = 0; $i -lt $Samples -and $runningIDs.Count -gt 0; $i++) { if ($i -gt 0) { Start-Sleep -Seconds $IntervalSeconds } $stats = @(Invoke-DockerJSON -JsonLines -DockerArgs (@('stats', '--no-stream', '--format', '{"id":{{json .ID}},"cpu_percent":{{json .CPUPerc}},"memory_usage":{{json .MemUsage}},"memory_percent":{{json .MemPerc}},"network_io":{{json .NetIO}},"block_io":{{json .BlockIO}},"pids":{{json .PIDs}}}') + $runningIDs)) $missingIDs = @() foreach ($expectedID in $runningIDs) { $matches = @($stats | Where-Object { $sampleID = [string]$_.id $sampleID -match '^[a-f0-9]{12,64}$' -and $expectedID.StartsWith($sampleID) }) if ($matches.Count -ne 1) { $missingIDs += $expectedID } } $complete = $missingIDs.Count -eq 0 -and $stats.Count -eq $runningIDs.Count if (-not $complete) { $allSamplesComplete = $false $report.issues += 'incomplete-stats-sample' } $report.samples += [ordered]@{ observed_at_utc = [DateTime]::UtcNow.ToString('o'); complete = $complete; containers = $stats } } $report.status = if ($runningIDs.Count -gt 0 -and $allSamplesComplete) { 'collected' } else { 'partial' } } catch { # Do not expose arbitrary exception text, which may include native output. $known = @('docker-cli-unavailable', 'compose-file-unavailable', 'docker-command-failed', 'docker-json-invalid', 'compose-ps-failed') $reason = $_.Exception.Message if ($reason -notin $known) { $reason = 'collection-failed' } $report.issues += $reason } finally { $absoluteOutput = [IO.Path]::GetFullPath($OutputPath) $parent = Split-Path $absoluteOutput -Parent if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $absoluteOutput -Encoding UTF8 Write-Output ('Baseline status: {0}; report: {1}' -f $report.status, $absoluteOutput) } if ($report.status -ne 'collected') { exit 2 }