feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
924f111e48
commit
f8e0520817
197 files changed
+18655
-1416
No files matched your search
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build historical source, not today's implementation with a legacy DTO.
|
||||
# Keep this commit pinned; a moved release tag must not silently change the gate.
|
||||
case "${EVOBGP_LEGACY_VERSION:-2.56.2}" in
|
||||
2.56.2) legacy_ref=924f111e4871e1d6d47c9916d7da1b9051718f1c ;;
|
||||
1.0.0) legacy_ref=6ca34b900859665eede22044d734b698059bdf84 ;;
|
||||
*) echo 'Unsupported legacy test version' >&2; exit 2 ;;
|
||||
esac
|
||||
if ! git cat-file -e "$legacy_ref^{commit}" 2>/dev/null; then
|
||||
git fetch --no-tags --depth=1 origin "$legacy_ref"
|
||||
fi
|
||||
legacy_tmp="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$legacy_tmp"' EXIT
|
||||
mkdir "$legacy_tmp/source"
|
||||
archive_paths=(go.mod go.sum cmd/evobgp-node internal migrations)
|
||||
if [ "${EVOBGP_LEGACY_VERSION:-2.56.2}" = 2.56.2 ]; then
|
||||
archive_paths+=(cmd/evobgp-agent)
|
||||
fi
|
||||
git archive "$legacy_ref" "${archive_paths[@]}" |
|
||||
tar -x -C "$legacy_tmp/source"
|
||||
(
|
||||
cd "$legacy_tmp/source"
|
||||
go build -mod=readonly -o "$legacy_tmp/evobgp-node-legacy" ./cmd/evobgp-node
|
||||
if [ "${EVOBGP_LEGACY_VERSION:-2.56.2}" = 2.56.2 ]; then
|
||||
go build -mod=readonly -o "$legacy_tmp/evobgp-agent-legacy" ./cmd/evobgp-agent
|
||||
fi
|
||||
)
|
||||
export EVOBGP_TEST_LEGACY_NODE_BIN="$legacy_tmp/evobgp-node-legacy"
|
||||
go test -race -count=1 -json -run '^TestLegacyNodeBinaryCurrentControlPlane$' ./internal/httpapi |
|
||||
tee "$legacy_tmp/result.jsonl"
|
||||
assert_test() {
|
||||
python3 - "$1" "$2" "$3" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
events = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")]
|
||||
package, expected = sys.argv[2:4]
|
||||
if any(e.get("Package") != package for e in events):
|
||||
raise SystemExit("unexpected legacy compatibility package")
|
||||
if any(e.get("Action") in ("skip", "fail") for e in events):
|
||||
raise SystemExit("legacy compatibility test skipped or failed")
|
||||
if not any(e.get("Test") == expected and e.get("Action") == "pass" for e in events):
|
||||
raise SystemExit("required legacy binary compatibility test did not execute")
|
||||
if not any("Test" not in e and e.get("Action") == "pass" for e in events):
|
||||
raise SystemExit("legacy compatibility package did not pass")
|
||||
print(f"Pinned legacy executable passed {expected}")
|
||||
PY
|
||||
}
|
||||
assert_test "$legacy_tmp/result.jsonl" evobgp/internal/httpapi TestLegacyNodeBinaryCurrentControlPlane
|
||||
if [ "${EVOBGP_LEGACY_VERSION:-2.56.2}" = 2.56.2 ]; then
|
||||
export EVOBGP_TEST_LEGACY_AGENT_BIN="$legacy_tmp/evobgp-agent-legacy"
|
||||
go test -race -count=1 -json -run '^TestLegacyAgentBinaryCurrentDispatch$' ./internal/nodedispatch |
|
||||
tee "$legacy_tmp/agent-result.jsonl"
|
||||
assert_test "$legacy_tmp/agent-result.jsonl" evobgp/internal/nodedispatch TestLegacyAgentBinaryCurrentDispatch
|
||||
fi
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${EVOBGP_TEST_DATABASE_URL:?PostgreSQL integration tests require EVOBGP_TEST_DATABASE_URL}"
|
||||
|
||||
assert_passes() {
|
||||
local result_file="$1" package="$2" label="$3"
|
||||
shift 3
|
||||
python3 - "$result_file" "$package" "$label" "$@" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
path, package, label = sys.argv[1:4]
|
||||
required = set(sys.argv[4:])
|
||||
passed = set()
|
||||
skipped = set()
|
||||
failed = set()
|
||||
package_passed = False
|
||||
unexpected_packages = set()
|
||||
with open(path, encoding="utf-8") as events:
|
||||
for line in events:
|
||||
event = json.loads(line)
|
||||
if event.get("Package") != package:
|
||||
unexpected_packages.add(event.get("Package", "<none>"))
|
||||
continue
|
||||
action = event.get("Action")
|
||||
test = event.get("Test")
|
||||
if action == "pass" and test:
|
||||
passed.add(test)
|
||||
elif action == "skip" and test:
|
||||
skipped.add(test)
|
||||
elif action == "fail":
|
||||
failed.add(test or "<package>")
|
||||
elif action == "pass" and not test:
|
||||
package_passed = True
|
||||
missing = required - passed
|
||||
if failed or skipped or missing or unexpected_packages or not package_passed:
|
||||
raise SystemExit(
|
||||
f"{label}: required PostgreSQL tests did not pass: "
|
||||
f"missing={sorted(missing)}, skipped={sorted(skipped)}, "
|
||||
f"failed={sorted(failed)}, unexpected_packages={sorted(unexpected_packages)}, "
|
||||
f"package_passed={package_passed}"
|
||||
)
|
||||
print(f"{label}: all {len(required)} required PostgreSQL integration tests passed, 0 skipped")
|
||||
PY
|
||||
}
|
||||
|
||||
db_result="$(mktemp)"
|
||||
repo_result="$(mktemp)"
|
||||
trap 'rm -f "$db_result" "$repo_result"' EXIT
|
||||
|
||||
# Run packages serially against the ephemeral database. Migration concurrency is
|
||||
# exercised explicitly inside the isolated-schema DB tests.
|
||||
go test -race -count=1 -p 1 -json -run '^TestPostgresMigration.*Integration$' ./internal/db | tee "$db_result"
|
||||
assert_passes "$db_result" evobgp/internal/db 'DB migrations' \
|
||||
TestPostgresMigrationsIntegration \
|
||||
TestPostgresMigrationConcurrentApplyIntegration \
|
||||
TestPostgresMigrationRollbackIntegration \
|
||||
TestPostgresMigrationIdempotentIntegration \
|
||||
TestPostgresMigrationLockCancellationIntegration
|
||||
go test -race -count=1 -p 1 -json -run 'Integration$' ./internal/repository | tee "$repo_result"
|
||||
assert_passes "$repo_result" evobgp/internal/repository 'Repository' \
|
||||
TestPostgresPingIntegration \
|
||||
TestPostgresModuleCRUDIntegration \
|
||||
TestPostgresFirewallClientCreateAndGetIntegration \
|
||||
TestEnsurePrefixSnapshotFillsEmptyExistingIntegration \
|
||||
TestEnsurePrefixSnapshotIdempotentIntegration \
|
||||
TestPostgresPruneUnreferencedPrefixSnapshotsIntegration \
|
||||
TestPostgresEstimateRevisionPruneIntegration \
|
||||
TestJobAuditClaimAndRestartIntegration \
|
||||
TestJobAuditIdempotencyRowIntegration \
|
||||
TestJobQueueRetryBoundsAndFailureIntegration \
|
||||
TestJobQueueFreshSeedIntegration \
|
||||
TestDeploymentLeaseGenerationAndRestartIntegration \
|
||||
TestTelemetryLeaseSnapshotAndDiscoveryIntegration \
|
||||
TestPostgresModuleSnapshotAtomicRMWMaxPoolOneIntegration \
|
||||
TestPostgresModuleSnapshotAdvisoryWaitCancellationIntegration \
|
||||
TestPostgresModuleSnapshotPartialWriteRollbackIntegration \
|
||||
TestPostgresModuleSnapshotKilledBackendRollsBackIntegration \
|
||||
TestPostgresModuleSnapshotMethodsRespectCancellationIntegration
|
||||
@@ -0,0 +1,133 @@
|
||||
# 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 }
|
||||
+15
-1
@@ -2,11 +2,24 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location (Join-Path $PSScriptRoot '..')
|
||||
|
||||
$unfmt = gofmt -l . 2>$null
|
||||
# Enumerate packages in this module instead of recursively formatting unrelated
|
||||
# checkouts such as .claude/worktrees. Include tagged tests in each package dir.
|
||||
$packageDirs = @(go list -f '{{.Dir}}' ./...)
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
$unfmt = @(
|
||||
foreach ($packageDir in ($packageDirs | Sort-Object -Unique)) {
|
||||
$sourceFiles = @(Get-ChildItem -LiteralPath $packageDir -File -Filter '*.go' | Select-Object -ExpandProperty FullName)
|
||||
if ($sourceFiles.Count -gt 0) {
|
||||
gofmt -l @sourceFiles
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
}
|
||||
)
|
||||
if ($unfmt) {
|
||||
Write-Error "gofmt: unformatted files:`n$unfmt"
|
||||
}
|
||||
go vet ./...
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
$golangci = Get-Command golangci-lint -ErrorAction SilentlyContinue
|
||||
if (-not $golangci) {
|
||||
$golangciPath = Join-Path $env:USERPROFILE 'go\bin\golangci-lint.exe'
|
||||
@@ -14,6 +27,7 @@ if (-not $golangci) {
|
||||
}
|
||||
if ($golangci) {
|
||||
& $golangci.Source run
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
} else {
|
||||
Write-Warning 'lint-go: golangci-lint not found, skipping (CI will run it)'
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Offline contract test: no Docker daemon or production access needed.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$output = Join-Path $repoRoot 'artifacts/backend-baseline/offline-test.json'
|
||||
$mockState = @{ Calls = [Collections.Generic.List[object]]::new(); EmptyStats = $false }
|
||||
function docker {
|
||||
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$DockerArgs)
|
||||
$mockState.Calls.Add($DockerArgs)
|
||||
$global:LASTEXITCODE = 0
|
||||
switch ($DockerArgs[0]) {
|
||||
'compose' {
|
||||
if ($DockerArgs -contains 'config') {
|
||||
'{"services":{"api":{"image":"example/api:test","environment":{"PASSWORD":"MUST_NOT_LEAK"},"command":"MUST_NOT_LEAK","deploy":{"resources":{"limits":{"memory":"280M","cpus":"0.85"}}}}}}'
|
||||
} else { 'aaaaaaaaaaaa'; 'bbbbbbbbbbbb' }
|
||||
}
|
||||
'info' { '{"server_version":"test","os_type":"linux","cpus":2,"memory_bytes":1073741824}' }
|
||||
'inspect' {
|
||||
'{"id":"' + $DockerArgs[-1] + '","image_id":"sha256:test","running":true,"memory_limit_bytes":293601280,"oom_killed":false,"restart_count":0}'
|
||||
}
|
||||
'stats' {
|
||||
if ($mockState.EmptyStats) { return }
|
||||
'{"id":"aaaaaaaaaaaa","cpu_percent":"1.0%","memory_usage":"20MiB / 280MiB"}'
|
||||
'{"id":"bbbbbbbbbbbb","cpu_percent":"2.0%","memory_usage":"30MiB / 280MiB"}'
|
||||
}
|
||||
default { throw 'unexpected Docker operation' }
|
||||
}
|
||||
}
|
||||
& (Join-Path $repoRoot 'scripts/collect-backend-baseline.ps1') -OutputPath $output -Samples 1
|
||||
$raw = Get-Content -LiteralPath $output -Raw
|
||||
$result = $raw | ConvertFrom-Json
|
||||
if ($result.status -ne 'collected') { throw 'collector did not succeed' }
|
||||
if ($result.containers.Count -ne 2 -or $result.samples[0].containers.Count -ne 2) { throw 'NDJSON multi-container stats were lost' }
|
||||
if ($raw.Contains('MUST_NOT_LEAK') -or $raw.Contains('PASSWORD')) { throw 'raw Compose secrets leaked' }
|
||||
if ($result.configured_services[0].memory_limit -ne '280M') { throw 'effective resource limit missing' }
|
||||
if ($mockState.Calls.Count -eq 0) { throw 'Docker mock was not exercised' }
|
||||
foreach ($call in $mockState.Calls) {
|
||||
if ($call[0] -notin @('compose', 'info', 'inspect', 'stats')) { throw 'unexpected operation' }
|
||||
if ($call[0] -eq 'compose' -and -not ($call -contains 'config' -or $call -contains 'ps')) { throw 'mutating Compose operation' }
|
||||
}
|
||||
$mockState.EmptyStats = $true
|
||||
& (Join-Path $repoRoot 'scripts/collect-backend-baseline.ps1') -OutputPath $output -Samples 1
|
||||
if ($LASTEXITCODE -ne 2) { throw 'missing stats must exit 2' }
|
||||
$partial = Get-Content -LiteralPath $output -Raw | ConvertFrom-Json
|
||||
if ($partial.status -ne 'partial' -or $partial.samples[0].complete -ne $false) { throw 'empty stats reported as complete' }
|
||||
Write-Output 'PASS: multi-container snapshot, resource projection, secret exclusion, read-only operations, incomplete stats detection'
|
||||
exit 0
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env sh
|
||||
# Offline behavioral checks for deploy/docker/mirror-base-images.sh.
|
||||
set -eu
|
||||
|
||||
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
script="$repo_root/deploy/docker/mirror-base-images.sh"
|
||||
test_dir="$(mktemp -d)"
|
||||
case_env="$test_dir/mirror.env"
|
||||
case_log="$test_dir/calls.log"
|
||||
case_out="$test_dir/output.log"
|
||||
copy_count_file="$test_dir/copy-count"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$test_dir/docker" "$test_dir/sleep" "$case_env" "$case_log" "$case_out" "$copy_count_file"
|
||||
rmdir "$test_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cat > "$test_dir/docker" <<'EOF'
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
case "$1 $2 $3" in
|
||||
'buildx imagetools inspect')
|
||||
if [ "${4:-}" = '--format' ]; then
|
||||
printf 'digest %s\n' "$6" >> "$CASE_LOG"
|
||||
[ "$TEST_BAD_DIGEST" = 0 ] || exit 1
|
||||
printf 'sha256:%s\n' 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
else
|
||||
printf 'inspect %s\n' "$4" >> "$CASE_LOG"
|
||||
[ "$TEST_EXISTING" = 1 ]
|
||||
fi
|
||||
;;
|
||||
'buildx imagetools create')
|
||||
n="$(cat "$COPY_COUNT_FILE")"
|
||||
n="$((n + 1))"
|
||||
printf '%s\n' "$n" > "$COPY_COUNT_FILE"
|
||||
printf 'copy %s\n' "$8" >> "$CASE_LOG"
|
||||
[ "$TEST_COPY_ALWAYS_FAIL" = 0 ] && [ "$n" -gt "$TEST_FAIL_FIRST" ]
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected docker call: %s\n' "$*" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
cat > "$test_dir/sleep" <<'EOF'
|
||||
#!/usr/bin/env sh
|
||||
printf 'sleep %s\n' "$1" >> "$CASE_LOG"
|
||||
EOF
|
||||
chmod +x "$test_dir/docker" "$test_dir/sleep"
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
cat "$case_out" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
count_calls() {
|
||||
awk -v kind="$1" '$1 == kind { count++ } END { print count + 0 }' "$case_log"
|
||||
}
|
||||
|
||||
run_case() {
|
||||
label="$1"
|
||||
TEST_EXISTING="$2"
|
||||
MIRROR_REFRESH="$3"
|
||||
TEST_FAIL_FIRST="$4"
|
||||
TEST_COPY_ALWAYS_FAIL="$5"
|
||||
TEST_BAD_DIGEST="$6"
|
||||
expected_status="$7"
|
||||
export TEST_EXISTING MIRROR_REFRESH TEST_FAIL_FIRST TEST_COPY_ALWAYS_FAIL TEST_BAD_DIGEST
|
||||
: > "$case_log"
|
||||
printf '0\n' > "$copy_count_file"
|
||||
rm -f "$case_env"
|
||||
if REGISTRY=example.test/owner MIRROR_ENV_FILE="$case_env" MIRROR_RETRIES=2 \
|
||||
CASE_LOG="$case_log" COPY_COUNT_FILE="$copy_count_file" PATH="$test_dir:$PATH" \
|
||||
sh "$script" > "$case_out" 2>&1; then
|
||||
status=0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
if [ "$expected_status" = success ] && [ "$status" -ne 0 ]; then
|
||||
fail "$label returned $status, expected success"
|
||||
fi
|
||||
if [ "$expected_status" = failure ] && [ "$status" -eq 0 ]; then
|
||||
fail "$label succeeded, expected failure"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_calls() {
|
||||
kind="$1"
|
||||
expected="$2"
|
||||
actual="$(count_calls "$kind")"
|
||||
[ "$actual" -eq "$expected" ] || fail "expected $expected $kind calls, got $actual"
|
||||
}
|
||||
|
||||
assert_immutable_refs() {
|
||||
[ -f "$case_env" ] || fail 'mirror env file missing'
|
||||
lines="$(wc -l < "$case_env" | tr -d ' ')"
|
||||
[ "$lines" -eq 6 ] || fail "expected 6 mirror refs, got $lines"
|
||||
if grep -v -E '^BASE_[A-Z_]+=example\.test/owner/evobgp-buildcache@sha256:[a-f0-9]{64}$' "$case_env"; then
|
||||
fail 'mirror env contains mutable or malformed image ref'
|
||||
fi
|
||||
}
|
||||
|
||||
run_case existing 1 0 0 0 0 success
|
||||
assert_calls copy 0
|
||||
assert_calls sleep 0
|
||||
assert_immutable_refs
|
||||
|
||||
run_case new 0 0 0 0 0 success
|
||||
assert_calls copy 6
|
||||
assert_calls sleep 0
|
||||
assert_immutable_refs
|
||||
|
||||
run_case retry 0 0 1 0 0 success
|
||||
assert_calls copy 7
|
||||
assert_calls sleep 1
|
||||
assert_immutable_refs
|
||||
|
||||
run_case refresh 1 1 0 0 0 success
|
||||
assert_calls copy 6
|
||||
assert_calls sleep 0
|
||||
assert_immutable_refs
|
||||
|
||||
run_case refresh_copy_failure 1 1 0 1 0 failure
|
||||
assert_calls copy 2
|
||||
assert_calls sleep 1
|
||||
[ ! -f "$case_env" ] || fail 'failed refresh wrote mirror refs'
|
||||
|
||||
run_case refresh_digest_failure 1 1 0 0 1 failure
|
||||
assert_calls copy 1
|
||||
assert_calls sleep 0
|
||||
[ ! -f "$case_env" ] || fail 'failed digest lookup wrote mirror refs'
|
||||
|
||||
run_case fallback 0 0 0 1 0 success
|
||||
assert_calls copy 12
|
||||
assert_calls sleep 6
|
||||
[ ! -s "$case_env" ] || fail 'fallback wrote stale mirror refs'
|
||||
|
||||
run_case digest_fallback 1 0 0 0 1 success
|
||||
assert_calls copy 0
|
||||
assert_calls sleep 0
|
||||
[ ! -s "$case_env" ] || fail 'digest fallback wrote mutable mirror refs'
|
||||
|
||||
printf 'mirror-base-images offline tests passed\n'
|
||||
Reference in new issue
Block a user