perf(ci): 迁移自托管运行器并优化并行验收 (#634)
* perf(ci): 切换现有自托管运行器并复用本机缓存 * perf(ci): 将主要 Linux 任务迁至 Mac ARM64 运行器 * fix(ci): 使用动态端口执行 Windows 安装验收 * test(cpa): 等待异步处理完成后校验 WebSocket 行为 * perf(ci): 并行数据库测试并隔离容器验收端口 * ci: 为数据库合同测试启用内存盘 * test(cpa): 固定断连日志脱敏测试的执行顺序 * fix(ci): 修复自托管验收中断和架构兼容问题 * fix(ci): 原子准备 Windows 安装验收配置
This commit is contained in:
1 parent
9d38793dd0
commit
d01656a1d8
30 files changed
+1115
-161
No files matched your search
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 两组互补:增量迁移契约单独运行,其余(包括未来新增测试)自动归入另一组。
|
||||
migrations='^TestExternal(AccessKeyPrefix|CustomAccessKey|PriceMultiplier|ModelCooldown|ValidationProtocol)MigrationContract$'
|
||||
run='^TestExternal'
|
||||
skip='^$'
|
||||
case "${1:-all}" in
|
||||
all | postgres) ;;
|
||||
mysql-migrations) run="${migrations}" ;;
|
||||
mysql-rest) skip="${migrations}" ;;
|
||||
mysql)
|
||||
: "${GPT_LOAD_DATABASE_TEST_DSN:?primary MySQL DSN is required}"
|
||||
: "${GPT_LOAD_DATABASE_SECONDARY_DSN:?secondary MySQL DSN is required}"
|
||||
logs="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/mysql-contract.XXXXXX")"
|
||||
cleanup() {
|
||||
for pid in $(jobs -pr); do
|
||||
kill "${pid}" 2>/dev/null || true
|
||||
done
|
||||
wait || true
|
||||
rm -rf "${logs}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
bash "$0" mysql-migrations >"${logs}/migrations.log" 2>&1 &
|
||||
migrations_pid=$!
|
||||
GPT_LOAD_DATABASE_TEST_DSN="${GPT_LOAD_DATABASE_SECONDARY_DSN}" \
|
||||
bash "$0" mysql-rest >"${logs}/rest.log" 2>&1 &
|
||||
rest_pid=$!
|
||||
result=0
|
||||
wait "${migrations_pid}" || result=1
|
||||
wait "${rest_pid}" || result=1
|
||||
for shard in migrations rest; do
|
||||
echo "::group::MySQL ${shard}"
|
||||
cat "${logs}/${shard}.log"
|
||||
echo '::endgroup::'
|
||||
done
|
||||
exit "${result}"
|
||||
;;
|
||||
*) echo "unknown database test shard: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
exec go test -v -count=1 ./internal/storage ./internal/control ./internal/requestlog \
|
||||
-run "${run}" -skip "${skip}"
|
||||
@@ -17,14 +17,23 @@ $dataDir = Join-Path $configDir "data"
|
||||
$envFile = Join-Path $configDir ".env"
|
||||
$dataOwnerMarker = Join-Path $configDir ".service-smoke-owner"
|
||||
|
||||
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
|
||||
throw "refusing pre-existing Windows service: $serviceName"
|
||||
}
|
||||
if (Test-Path $installDir) {
|
||||
throw "refusing pre-existing installation directory: $installDir"
|
||||
}
|
||||
if (Test-Path $configDir) {
|
||||
throw "refusing pre-existing ProgramData directory: $configDir"
|
||||
. "$PSScriptRoot/windows-smoke-recovery.ps1"
|
||||
$smokeMutex = Enter-WindowsSmoke -InstallDir $installDir -ConfigDir $configDir
|
||||
|
||||
try {
|
||||
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
|
||||
throw "refusing pre-existing Windows service: $serviceName"
|
||||
}
|
||||
if (Test-Path $installDir) {
|
||||
throw "refusing pre-existing installation directory: $installDir"
|
||||
}
|
||||
if (Test-Path $configDir) {
|
||||
throw "refusing pre-existing ProgramData directory: $configDir"
|
||||
}
|
||||
} catch {
|
||||
$smokeMutex.ReleaseMutex()
|
||||
$smokeMutex.Dispose()
|
||||
throw
|
||||
}
|
||||
|
||||
function Assert-ServiceAcl {
|
||||
@@ -131,4 +140,6 @@ try {
|
||||
((Get-Content $dataOwnerMarker -Raw).Trim() -eq $installOwnerToken)) {
|
||||
Remove-Item -Recurse -Force $configDir -ErrorAction SilentlyContinue
|
||||
}
|
||||
$smokeMutex.ReleaseMutex()
|
||||
$smokeMutex.Dispose()
|
||||
}
|
||||
@@ -38,8 +38,7 @@ fake_container="gpt-load-release-fake-${suffix}"
|
||||
fake_alias="fake-upstream"
|
||||
volume="gpt-load-release-smoke-${suffix}"
|
||||
network="gpt-load-release-network-${suffix}"
|
||||
app_port="${RELEASE_SMOKE_APP_PORT:-39413}"
|
||||
base_url="http://127.0.0.1:${app_port}"
|
||||
app_port="${RELEASE_SMOKE_APP_PORT:-0}"
|
||||
task_tmp="$(mktemp -d)"
|
||||
smoke_stage="preflight"
|
||||
|
||||
@@ -55,6 +54,8 @@ cleanup_temp() {
|
||||
fi
|
||||
}
|
||||
trap cleanup_temp EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
for target in "${container}" "${probe}" "${fake_container}"; do
|
||||
if docker container inspect "${target}" >/dev/null 2>&1; then
|
||||
@@ -207,9 +208,14 @@ start_container() {
|
||||
docker run -d \
|
||||
--name "${container}" \
|
||||
--network "${network}" \
|
||||
--publish "${app_port}:3001" \
|
||||
--publish "127.0.0.1:${app_port}:3001" \
|
||||
--volume "${volume}:/app/data" \
|
||||
"${image}" >/dev/null
|
||||
# Docker 原子分配可用端口;重建容器后也重新查询,避免多个 Runner 抢占端口。
|
||||
local binding
|
||||
binding="$(docker port "${container}" 3001/tcp)"
|
||||
[[ "${binding}" =~ ^127\.0\.0\.1:[0-9]+$ ]]
|
||||
base_url="http://${binding}"
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
|
||||
@@ -28,8 +28,11 @@ $installOwnerToken = $suffix
|
||||
$programData = [Environment]::GetFolderPath("CommonApplicationData")
|
||||
$configDir = Join-Path $programData "GPT-Load"
|
||||
$dataDir = Join-Path $configDir "data"
|
||||
$envFile = Join-Path $configDir ".env"
|
||||
$dataOwnerMarker = Join-Path $configDir ".installer-smoke-owner"
|
||||
$failureDataMarker = Join-Path $dataDir "installer-smoke-failure.txt"
|
||||
$preparedConfig = Join-Path $programData ".gpt-load-installer-smoke-$suffix"
|
||||
$ownsPreparedConfig = $false
|
||||
$upgradeMarker = Join-Path $dataDir "installer-smoke-upgrade.txt"
|
||||
$installedBinary = Join-Path $installDir "gpt-load.exe"
|
||||
$uninstaller = Join-Path $installDir "unins000.exe"
|
||||
@@ -43,19 +46,28 @@ if ([System.IO.Path]::GetFullPath($configDir) -ne
|
||||
[System.IO.Path]::GetFullPath((Join-Path $programData "GPT-Load"))) {
|
||||
throw "refusing unexpected ProgramData cleanup target: $configDir"
|
||||
}
|
||||
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
|
||||
throw "refusing pre-existing Windows service: $serviceName"
|
||||
}
|
||||
if (Test-Path $installDir) {
|
||||
throw "refusing pre-existing installation directory: $installDir"
|
||||
}
|
||||
if (Test-Path $configDir) {
|
||||
throw "refusing pre-existing ProgramData directory: $configDir"
|
||||
}
|
||||
foreach ($path in @($desktopShortcut, $startMenuShortcut, $uninstallKey)) {
|
||||
if (Test-Path $path) {
|
||||
throw "refusing pre-existing installer smoke path: $path"
|
||||
. "$PSScriptRoot/windows-smoke-recovery.ps1"
|
||||
$smokeMutex = Enter-WindowsSmoke -InstallDir $installDir -ConfigDir $configDir
|
||||
|
||||
try {
|
||||
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
|
||||
throw "refusing pre-existing Windows service: $serviceName"
|
||||
}
|
||||
if (Test-Path $installDir) {
|
||||
throw "refusing pre-existing installation directory: $installDir"
|
||||
}
|
||||
if (Test-Path $configDir) {
|
||||
throw "refusing pre-existing ProgramData directory: $configDir"
|
||||
}
|
||||
foreach ($path in @($desktopShortcut, $startMenuShortcut, $uninstallKey)) {
|
||||
if (Test-Path $path) {
|
||||
throw "refusing pre-existing installer smoke path: $path"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$smokeMutex.ReleaseMutex()
|
||||
$smokeMutex.Dispose()
|
||||
throw
|
||||
}
|
||||
|
||||
function Invoke-CheckedProcess {
|
||||
@@ -112,17 +124,28 @@ function Assert-ServiceAcl {
|
||||
|
||||
try {
|
||||
[System.IO.File]::WriteAllText($installOwnerMarker, $installOwnerToken)
|
||||
New-Item -ItemType Directory -Path $configDir | Out-Null
|
||||
[System.IO.File]::WriteAllText($dataOwnerMarker, $installOwnerToken)
|
||||
New-Item -ItemType Directory -Path $dataDir | Out-Null
|
||||
[System.IO.File]::WriteAllText($failureDataMarker, $installOwnerToken)
|
||||
# 同盘准备完整归属凭据后再公开固定目录,避免中断留下半初始化状态。
|
||||
New-Item -ItemType Directory -Path $preparedConfig | Out-Null
|
||||
$ownsPreparedConfig = $true
|
||||
[System.IO.File]::WriteAllText((Join-Path $preparedConfig ".installer-smoke-owner"), $installOwnerToken)
|
||||
New-Item -ItemType Directory -Path (Join-Path $preparedConfig "data") | Out-Null
|
||||
[System.IO.File]::WriteAllText((Join-Path $preparedConfig "data/installer-smoke-failure.txt"), $installOwnerToken)
|
||||
[System.IO.Directory]::Move($preparedConfig, $configDir)
|
||||
|
||||
$listener = [System.Net.Sockets.TcpListener]::new(
|
||||
[System.Net.IPAddress]::Loopback,
|
||||
3001
|
||||
0
|
||||
)
|
||||
# 由系统分配可用端口,避开自托管 Windows 的端口排除范围。
|
||||
$listener.ExclusiveAddressUse = $true
|
||||
$listener.Start()
|
||||
try {
|
||||
$port = $listener.LocalEndpoint.Port
|
||||
# 测试服务读取同一端口;独占监听保持到安装失败回滚验收结束。
|
||||
@(
|
||||
"HOST=127.0.0.1",
|
||||
"PORT=$port"
|
||||
) | Set-Content -Path $envFile -Encoding utf8NoBOM
|
||||
Invoke-CheckedProcess -Path $setup -Arguments @(
|
||||
"/VERYSILENT",
|
||||
"/SUPPRESSMSGBOXES",
|
||||
@@ -179,7 +202,7 @@ try {
|
||||
$health = $null
|
||||
for ($attempt = 0; $attempt -lt 80; $attempt++) {
|
||||
try {
|
||||
$health = Invoke-RestMethod "http://127.0.0.1:3001/health"
|
||||
$health = Invoke-RestMethod "http://127.0.0.1:$port/health"
|
||||
break
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 250
|
||||
@@ -196,7 +219,7 @@ try {
|
||||
}
|
||||
$authKey = (Get-Content $authFile -Raw).Trim()
|
||||
$headers = @{ Authorization = "Bearer $authKey" }
|
||||
Invoke-RestMethod "http://127.0.0.1:3001/api/system/info" -Headers $headers | Out-Null
|
||||
Invoke-RestMethod "http://127.0.0.1:$port/api/system/info" -Headers $headers | Out-Null
|
||||
|
||||
[System.IO.File]::WriteAllText($upgradeMarker, $suffix)
|
||||
Invoke-CheckedProcess -Path $setup -Arguments @(
|
||||
@@ -227,6 +250,9 @@ try {
|
||||
$afterHash = (Get-FileHash -Algorithm SHA256 $setup).Hash.ToLowerInvariant()
|
||||
if ($afterHash -ne $expectedHash) { throw "Windows setup checksum mismatch after execution" }
|
||||
} finally {
|
||||
if ($ownsPreparedConfig -and (Test-Path -LiteralPath $preparedConfig)) {
|
||||
Remove-Item -LiteralPath $preparedConfig -Recurse -Force
|
||||
}
|
||||
$ownsInstall = (Test-Path $installOwnerMarker) -and
|
||||
((Get-Content $installOwnerMarker -Raw).Trim() -eq $installOwnerToken)
|
||||
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
|
||||
@@ -257,4 +283,6 @@ try {
|
||||
((Get-Content $installOwnerMarker -Raw).Trim() -eq $installOwnerToken)) {
|
||||
Remove-Item -Force $installOwnerMarker -ErrorAction SilentlyContinue
|
||||
}
|
||||
$smokeMutex.ReleaseMutex()
|
||||
$smokeMutex.Dispose()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
function Enter-WindowsSmoke {
|
||||
param([string]$InstallDir, [string]$ConfigDir)
|
||||
|
||||
# 固定服务名只能由一个 smoke 使用;进程退出后系统自动释放互斥锁。
|
||||
$mutex = [System.Threading.Mutex]::new($false, "Global\GPTLoad-Windows-Smoke")
|
||||
$acquired = $false
|
||||
try {
|
||||
try { $acquired = $mutex.WaitOne(0) }
|
||||
catch [System.Threading.AbandonedMutexException] { $acquired = $true }
|
||||
if (-not $acquired) { throw "another Windows smoke is running" }
|
||||
|
||||
$owners = @()
|
||||
foreach ($directory in @($InstallDir, $ConfigDir)) {
|
||||
if (-not (Test-Path -LiteralPath $directory)) { continue }
|
||||
if ((Get-Item -LiteralPath $directory -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||
throw "refusing linked Windows smoke directory: $directory"
|
||||
}
|
||||
foreach ($name in @('.service-smoke-owner', '.installer-smoke-owner')) {
|
||||
$path = Join-Path $directory $name
|
||||
if (-not (Test-Path -LiteralPath $path)) { continue }
|
||||
$item = Get-Item -LiteralPath $path -Force
|
||||
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
throw "refusing invalid Windows smoke marker: $path"
|
||||
}
|
||||
$token = (Get-Content -LiteralPath $path -Raw).Trim()
|
||||
if ($token -notmatch '^[0-9a-fA-F]{32}$') { throw "invalid Windows smoke owner: $path" }
|
||||
$owners += @{ Name = $name; Token = $token }
|
||||
}
|
||||
}
|
||||
# 无标记时保留现场,让调用方的原有 preflight 拒绝真实安装。
|
||||
if ($owners.Count -eq 0) { return $mutex }
|
||||
$owner = $owners[0]
|
||||
foreach ($other in $owners) {
|
||||
if ($other.Name -ne $owner.Name -or $other.Token -ne $owner.Token) {
|
||||
throw "conflicting Windows smoke owners"
|
||||
}
|
||||
}
|
||||
$installer = $owner.Name -eq '.installer-smoke-owner'
|
||||
if ($installer) {
|
||||
# 安装器会删除、重建安装目录;其归属凭据保存在卸载时保留的 ProgramData。
|
||||
$proof = Join-Path $ConfigDir 'data/installer-smoke-failure.txt'
|
||||
if (-not (Test-Path -LiteralPath $proof) -or
|
||||
((Get-Content -LiteralPath $proof -Raw).Trim() -ne $owner.Token)) {
|
||||
throw "missing installer smoke ownership proof"
|
||||
}
|
||||
} else {
|
||||
foreach ($directory in @($InstallDir, $ConfigDir)) {
|
||||
if ((Test-Path -LiteralPath $directory) -and
|
||||
-not (Test-Path -LiteralPath (Join-Path $directory $owner.Name))) {
|
||||
throw "unmarked Windows smoke directory: $directory"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$binary = Join-Path $InstallDir 'gpt-load.exe'
|
||||
$service = Get-CimInstance Win32_Service -Filter "Name='gpt-load'" -ErrorAction Stop
|
||||
if ($null -ne $service) {
|
||||
if ($service.PathName -ine ('"' + $binary + '" service run') -or
|
||||
$service.StartName -ine 'NT AUTHORITY\LocalService') {
|
||||
throw "refusing Windows service with unexpected configuration"
|
||||
}
|
||||
$controller = Get-Service -Name 'gpt-load' -ErrorAction Stop
|
||||
try {
|
||||
if ($controller.Status -ne 'Stopped') {
|
||||
$controller.Stop()
|
||||
$controller.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15))
|
||||
}
|
||||
} finally { $controller.Dispose() }
|
||||
& sc.exe delete 'gpt-load' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "failed to remove stale Windows smoke service" }
|
||||
}
|
||||
|
||||
# 只回收已确认归属的固定目标,不扫描或清理其他任务、服务和目录。
|
||||
if (Test-Path -LiteralPath $InstallDir) { Remove-Item -LiteralPath $InstallDir -Recurse -Force }
|
||||
if ($installer) {
|
||||
$paths = @(
|
||||
([IO.Path]::Combine([Environment]::GetFolderPath('CommonDesktopDirectory'), 'GPT-Load.url')),
|
||||
([IO.Path]::Combine([Environment]::GetFolderPath('CommonPrograms'), 'GPT-Load/GPT-Load.url')),
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{E5A127DE-2676-4F6C-B763-CF53C6271883}_is1'
|
||||
)
|
||||
foreach ($path in $paths) {
|
||||
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force }
|
||||
}
|
||||
}
|
||||
# 最后删除归属标记所在目录;中途失败时保留下一次恢复所需的证据。
|
||||
if (Test-Path -LiteralPath $ConfigDir) { Remove-Item -LiteralPath $ConfigDir -Recurse -Force }
|
||||
return $mutex
|
||||
} catch {
|
||||
if ($acquired) { $mutex.ReleaseMutex() }
|
||||
$mutex.Dispose()
|
||||
throw
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user