Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <[email protected]>
134 lines
6.7 KiB
PowerShell
134 lines
6.7 KiB
PowerShell
# 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 }
|