Files
ai-contract-platform/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1
T

526 lines
26 KiB
PowerShell

param(
[string]$ServerHost = "192.168.3.90",
[string]$SshUser = "root",
[string]$SshKeyPath = "$HOME\.ssh\water_audit_ed25519",
[string]$RemoteProjectPath = "/opt/apps/ai-contract-platform-v2-smoke",
[string]$RemoteBackupRoot = "/opt/apps/ai-contract-platform-v2-smoke/backups",
[string]$ComposeProjectName = "yuqei-contract-v2-smoke",
[int]$PostgresHostPort = 15432,
[int]$RedisHostPort = 16379,
[int]$MinioApiHostPort = 19000,
[int]$MinioConsoleHostPort = 19001,
[int]$ContractApiHostPort = 18100,
[int]$AiPlatformApiHostPort = 18101,
[int]$ContractWebHostPort = 13000,
[string]$ArtifactRoot = "",
[switch]$UseSshpass,
[switch]$AllowDirtyWorktree,
[switch]$KeepRemoteBackup,
[switch]$ResetSmokeVolumes,
[switch]$SkipBuild,
[switch]$SkipSmoke,
[switch]$KeepRemoteArchive
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss"
$archiveName = "ai-contract-platform-v2-smoke-$timestamp.tgz"
if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) {
$ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\v2-smoke-docker-deploy"
}
if (-not [System.IO.Path]::IsPathRooted($ArtifactRoot)) {
$ArtifactRoot = Join-Path $repoRoot $ArtifactRoot
}
$artifactDir = Join-Path $ArtifactRoot $timestamp
$summaryJson = Join-Path $artifactDir "v2-smoke-docker-deploy-summary.json"
$summaryMd = Join-Path $artifactDir "v2-smoke-docker-deploy-summary.md"
$gitStatusPath = Join-Path $artifactDir "git-status.txt"
$localArchive = Join-Path $artifactDir $archiveName
$stagingRoot = Join-Path $artifactDir "release-staging"
$remoteArchive = "/tmp/$archiveName"
$envFileName = ".env.smoke"
$remoteEnvPath = "$RemoteProjectPath/yuqei-infra/compose/$envFileName"
$composeFile = "$RemoteProjectPath/yuqei-infra/compose/docker-compose.yml"
$webProxySharedSecret = "$([guid]::NewGuid().ToString('N'))$([guid]::NewGuid().ToString('N'))"
$webSessionSecret = "$([guid]::NewGuid().ToString('N'))$([guid]::NewGuid().ToString('N'))"
$aiPlatformServiceSecret = "$([guid]::NewGuid().ToString('N'))$([guid]::NewGuid().ToString('N'))"
$identityRateLimitSecret = "$([guid]::NewGuid().ToString('N'))$([guid]::NewGuid().ToString('N'))"
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
$checks = New-Object System.Collections.Generic.List[object]
$script:SshpassPath = $null
function Add-Check {
param(
[string]$Key,
[string]$Label,
[ValidateSet("pass", "fail", "warn", "skip")]
[string]$Status,
[string]$Detail,
[bool]$Required = $true,
[hashtable]$Meta = @{}
)
$checks.Add([ordered]@{
key = $Key
label = $Label
status = $Status
required = $Required
detail = $Detail
meta = $Meta
}) | Out-Null
}
function Save-Json {
param([string]$Path, [object]$Value)
$Value | ConvertTo-Json -Depth 14 | Set-Content -Encoding UTF8 -Path $Path
}
function ConvertTo-ShellSingleQuote {
param([string]$Value)
return "'" + ($Value -replace "'", "'`"`"`'") + "'"
}
function Invoke-LocalStep {
param([string]$Name, [scriptblock]$Script)
$logPath = Join-Path $artifactDir "$Name.log"
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $Script 2>&1
$exitCode = if ($LASTEXITCODE -is [int]) { $LASTEXITCODE } else { 0 }
} catch {
$output = @($_.Exception.Message)
$exitCode = 1
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
$outputText = ($output -join [Environment]::NewLine)
$outputText | Set-Content -Encoding UTF8 -Path $logPath
return [ordered]@{ exit_code = $exitCode; log = $logPath; output = $outputText }
}
function Resolve-SshpassPath {
$command = Get-Command sshpass.exe -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
$candidate = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Packages\xhcoding.sshpass-win32_Microsoft.Winget.Source_8wekyb3d8bbwe\sshpass.exe"
if (Test-Path -LiteralPath $candidate) { return $candidate }
return $null
}
function Invoke-Remote {
param([string]$Name, [string]$Command)
$target = "${SshUser}@${ServerHost}"
$logPath = Join-Path $artifactDir "$Name.log"
$normalizedCommand = ($Command -replace "`r`n", "`n").TrimStart([char]0xFEFF)
$encodedCommand = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($normalizedCommand))
$remoteCommand = "bash -lc " + (ConvertTo-ShellSingleQuote "set -o pipefail; echo $encodedCommand | base64 -d | bash -se")
if ($script:SshpassPath) {
$filePath = $script:SshpassPath
$args = @("-e", "ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $target, $remoteCommand)
} else {
$filePath = "ssh"
$args = @("-i", $SshKeyPath, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $target, $remoteCommand)
}
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $filePath @args 2>&1
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
$outputText = ($output -join [Environment]::NewLine)
$outputText | Set-Content -Encoding UTF8 -Path $logPath
return [ordered]@{ exit_code = $exitCode; output = $outputText; log = $logPath }
}
function Invoke-ScpToRemote {
param([string]$LocalPath, [string]$RemotePath)
$target = "${SshUser}@${ServerHost}:$RemotePath"
$logPath = Join-Path $artifactDir "upload-archive.log"
if ($script:SshpassPath) {
$filePath = $script:SshpassPath
$args = @("-e", "scp", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $LocalPath, $target)
} else {
$filePath = "scp"
$args = @("-i", $SshKeyPath, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $LocalPath, $target)
}
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $filePath @args 2>&1
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
($output -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $logPath
return [ordered]@{ exit_code = $exitCode; log = $logPath }
}
function Write-Summary {
param([string]$Status)
$summary = [ordered]@{
status = $Status
checked_at = (Get-Date).ToString("o")
server_host = $ServerHost
remote_project_path = $RemoteProjectPath
compose_project_name = $ComposeProjectName
urls = [ordered]@{
web = "http://${ServerHost}:$ContractWebHostPort"
contract_api = "http://${ServerHost}:$ContractApiHostPort"
ai_platform_api = "http://${ServerHost}:$AiPlatformApiHostPort"
minio_console = "http://${ServerHost}:$MinioConsoleHostPort"
}
artifact_dir = $artifactDir
checks = $checks
}
Save-Json $summaryJson $summary
$lines = @()
$lines += "# V2 Smoke Docker Deploy Summary"
$lines += ""
$lines += "- Status: $Status"
$lines += "- Server: $ServerHost"
$lines += "- Remote path: $RemoteProjectPath"
$lines += "- Compose project: $ComposeProjectName"
$lines += "- Web: http://${ServerHost}:$ContractWebHostPort"
$lines += "- Contract API: http://${ServerHost}:$ContractApiHostPort"
$lines += "- AI Platform API: http://${ServerHost}:$AiPlatformApiHostPort"
$lines += "- MinIO Console: http://${ServerHost}:$MinioConsoleHostPort"
$lines += ""
$lines += "## Checks"
foreach ($check in $checks) {
$lines += "- [$($check.status)] $($check.label): $($check.detail)"
}
$lines | Set-Content -Encoding UTF8 -Path $summaryMd
}
try {
Push-Location $repoRoot
$gitStatus = Invoke-LocalStep "git-status" { git status --short }
$gitStatus.output | Set-Content -Encoding UTF8 -Path $gitStatusPath
if ($gitStatus.exit_code -ne 0) {
Add-Check "git_status" "Git status" "fail" "Unable to read git status." $true @{ log = $gitStatus.log }
throw "Unable to read git status."
}
if (-not $AllowDirtyWorktree -and -not [string]::IsNullOrWhiteSpace($gitStatus.output)) {
Add-Check "dirty_worktree" "Dirty worktree gate" "fail" "Working tree has changes. Re-run with -AllowDirtyWorktree for smoke deploy." $true @{ log = $gitStatus.log }
throw "Working tree has changes."
}
Add-Check "git_status" "Git status" "pass" "Git status captured." $true @{ log = $gitStatus.log }
if ($UseSshpass) {
$script:SshpassPath = Resolve-SshpassPath
if (-not $script:SshpassPath) {
Add-Check "sshpass" "sshpass available" "fail" "sshpass.exe was not found." $true
throw "sshpass.exe was not found."
}
Add-Check "sshpass" "sshpass available" "pass" $script:SshpassPath
}
$preflight = Invoke-Remote "remote-preflight" @"
set -euo pipefail
command -v docker
docker --version
docker compose version
mkdir -p $(ConvertTo-ShellSingleQuote $RemoteProjectPath)
mkdir -p $(ConvertTo-ShellSingleQuote $RemoteBackupRoot)
"@
if ($preflight.exit_code -ne 0) {
Add-Check "remote_preflight" "Remote Docker preflight" "fail" "Remote Docker preflight failed." $true @{ log = $preflight.log }
throw "Remote Docker preflight failed. See $($preflight.log)"
}
Add-Check "remote_preflight" "Remote Docker preflight" "pass" "Remote host has docker compose." $true @{ log = $preflight.log }
$archive = Invoke-LocalStep "create-archive" {
if (Test-Path -LiteralPath $stagingRoot) {
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null
foreach ($file in @("package.json", "package-lock.json", "tsconfig.base.json", "pyproject.toml", "README.md", "V2-README.md", "AGENTS.md")) {
Copy-Item -LiteralPath (Join-Path $repoRoot $file) -Destination $stagingRoot -Force
}
$excludedDirs = @(
"node_modules",
".next",
".turbo",
".pytest_cache",
"__pycache__",
".mypy_cache",
".ruff_cache",
".runtime",
"dist",
"build",
"coverage",
".venv",
"qa-artifacts"
)
$excludedFiles = @(
"*.tgz",
"*.zip",
"*.log",
".env",
".env.local",
".env.production",
".env.smoke"
)
foreach ($dir in @("yuqei-ai-platform", "yuqei-ai-sdk-python", "yuqei-contract-platform", "yuqei-infra")) {
$sourceDir = Join-Path $repoRoot $dir
$targetDir = Join-Path $stagingRoot $dir
$robocopyArgs = @($sourceDir, $targetDir, "/E", "/NFL", "/NDL", "/NJH", "/NJS", "/NP", "/XD") + $excludedDirs + @("/XF") + $excludedFiles
& robocopy @robocopyArgs | Out-Null
if ($LASTEXITCODE -ge 8) {
throw "robocopy failed for $dir with exit code $LASTEXITCODE"
}
$global:LASTEXITCODE = 0
}
New-Item -ItemType Directory -Force -Path (Join-Path $stagingRoot "project-docs") | Out-Null
foreach ($doc in Get-ChildItem -LiteralPath (Join-Path $repoRoot "project-docs") -Filter "V2-*.md" -File -ErrorAction SilentlyContinue) {
Copy-Item -LiteralPath $doc.FullName -Destination (Join-Path $stagingRoot "project-docs") -Force
}
tar -czf $localArchive -C $stagingRoot .
}
if ($archive.exit_code -ne 0) {
Add-Check "create_archive" "Create release archive" "fail" "Archive creation failed." $true @{ log = $archive.log }
throw "Archive creation failed. See $($archive.log)"
}
Add-Check "create_archive" "Create release archive" "pass" $localArchive $true @{ log = $archive.log }
$upload = Invoke-ScpToRemote $localArchive $remoteArchive
if ($upload.exit_code -ne 0) {
Add-Check "upload_archive" "Upload release archive" "fail" "Archive upload failed." $true @{ log = $upload.log }
throw "Archive upload failed. See $($upload.log)"
}
Add-Check "upload_archive" "Upload release archive" "pass" $remoteArchive $true @{ log = $upload.log }
$deploy = Invoke-Remote "remote-deploy" @"
set -euo pipefail
REMOTE_PROJECT_PATH=$(ConvertTo-ShellSingleQuote $RemoteProjectPath)
REMOTE_BACKUP_ROOT=$(ConvertTo-ShellSingleQuote $RemoteBackupRoot)
REMOTE_ARCHIVE=$(ConvertTo-ShellSingleQuote $remoteArchive)
COMPOSE_PROJECT_NAME=$(ConvertTo-ShellSingleQuote $ComposeProjectName)
ENV_FILE=$(ConvertTo-ShellSingleQuote $remoteEnvPath)
COMPOSE_FILE=$(ConvertTo-ShellSingleQuote $composeFile)
mkdir -p "`$REMOTE_PROJECT_PATH" "`$REMOTE_BACKUP_ROOT"
if [ "$($KeepRemoteBackup.IsPresent.ToString().ToLowerInvariant())" = "true" ] && [ -f "`$REMOTE_PROJECT_PATH/yuqei-infra/compose/docker-compose.yml" ]; then
BACKUP="`$REMOTE_BACKUP_ROOT/before-$timestamp.tgz"
tar --exclude='./backups' --exclude='./project-docs/qa-artifacts' --exclude='./**/.next' --exclude='./**/node_modules' -czf "`$BACKUP" -C "`$REMOTE_PROJECT_PATH" .
fi
find "`$REMOTE_PROJECT_PATH" -maxdepth 1 -mindepth 1 ! -name backups -exec rm -rf {} +
tar -xzf "`$REMOTE_ARCHIVE" -C "`$REMOTE_PROJECT_PATH"
cat > "`$ENV_FILE" <<'EOF'
POSTGRES_USER=yuqei
POSTGRES_PASSWORD=yuqei-local-smoke
POSTGRES_DB=postgres
POSTGRES_IMAGE=pgvector/pgvector:pg16
PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
NPM_REGISTRY=https://registry.npmmirror.com
CONTRACT_POSTGRES_DB=yuqei_contract
AI_PLATFORM_POSTGRES_DB=yuqei_ai
OPS_POSTGRES_DB=yuqei_ops
POSTGRES_HOST_PORT=$PostgresHostPort
REDIS_HOST_PORT=$RedisHostPort
MINIO_API_HOST_PORT=$MinioApiHostPort
MINIO_CONSOLE_HOST_PORT=$MinioConsoleHostPort
CONTRACT_API_HOST_PORT=$ContractApiHostPort
AI_PLATFORM_API_HOST_PORT=$AiPlatformApiHostPort
CONTRACT_WEB_HOST_PORT=$ContractWebHostPort
MINIO_IMAGE=minio/minio:RELEASE.2023-06-23T20-26-00Z
MINIO_MC_IMAGE=minio/mc:latest
MINIO_ROOT_USER=yuqei-minio
MINIO_ROOT_PASSWORD=yuqei-minio-local-smoke
AI_PLATFORM_OBJECT_STORAGE_BUCKET=ai-platform-uploads
CONTRACT_API_ENVIRONMENT=staging
CONTRACT_API_DATABASE_URL=postgresql+psycopg://yuqei:yuqei-local-smoke@postgres:5432/yuqei_contract
CONTRACT_API_DATABASE_AUTO_CREATE=false
CONTRACT_API_SEED_DEMO_DATA=true
CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files
CONTRACT_API_REDIS_URL=redis://redis:6379/0
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=240
AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS=180
AI_PLATFORM_SERVICE_SHARED_SECRET=$aiPlatformServiceSecret
AI_PLATFORM_REQUIRE_SERVICE_AUTH=true
CONTRACT_API_WEB_PROXY_SHARED_SECRET=$webProxySharedSecret
CONTRACT_API_REQUIRE_WEB_PROXY_AUTH=true
CONTRACT_API_UVICORN_WORKERS=1
CONTRACT_API_READ_CACHE_TTL_SECONDS=2
CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-smoke-1
CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS=30
CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT=5
CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE=disabled
CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=false
CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true
CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=$identityRateLimitSecret
CONTRACT_REVIEW_WORKER_SEED_DEMO_DATA=false
CONTRACT_WEB_SESSION_SECRET=$webSessionSecret
CONTRACT_WEB_SESSION_SECURE=false
AI_PLATFORM_ENVIRONMENT=staging
AI_PLATFORM_DATABASE_URL=postgresql+psycopg://yuqei:yuqei-local-smoke@postgres:5432/yuqei_ai
AI_PLATFORM_DATABASE_AUTO_CREATE=false
AI_PLATFORM_REPOSITORY_BACKEND=sqlalchemy
AI_PLATFORM_REDIS_URL=redis://redis:6379/1
AI_PLATFORM_PROVIDER_CONFIG_SOURCE=database
AI_PLATFORM_PROVIDER_CALL_MODE=disabled
AI_PLATFORM_PROVIDER_FALLBACK_ENABLED=true
AI_PLATFORM_UVICORN_WORKERS=1
YUQEI_WORKER_ENVIRONMENT=staging
YUQEI_WORKER_DATABASE_URL=postgresql+psycopg://yuqei:yuqei-local-smoke@postgres:5432/yuqei_ai
YUQEI_WORKER_DATABASE_AUTO_CREATE=false
YUQEI_WORKER_REDIS_URL=redis://redis:6379/2
YUQEI_WORKER_TENANT_ID=default
YUQEI_WORKER_TRACE_GOVERNANCE_ENABLED=true
YUQEI_WORKER_TRACE_GOVERNANCE_INTERVAL_SECONDS=300
YUQEI_WORKER_TRACE_GOVERNANCE_BATCH_LIMIT=100
YUQEI_WORKER_TRACE_GOVERNANCE_WORKER_NAME=trace-governance-worker
EOF
cd "`$REMOTE_PROJECT_PATH"
if [ "$($ResetSmokeVolumes.IsPresent.ToString().ToLowerInvariant())" = "true" ]; then
docker compose -p "`$COMPOSE_PROJECT_NAME" --env-file "`$ENV_FILE" -f "`$COMPOSE_FILE" down --remove-orphans -v
else
docker compose -p "`$COMPOSE_PROJECT_NAME" --env-file "`$ENV_FILE" -f "`$COMPOSE_FILE" down --remove-orphans
fi
if [ "$($SkipBuild.IsPresent.ToString().ToLowerInvariant())" = "true" ]; then
docker compose -p "`$COMPOSE_PROJECT_NAME" --env-file "`$ENV_FILE" -f "`$COMPOSE_FILE" up -d
else
docker compose -p "`$COMPOSE_PROJECT_NAME" --env-file "`$ENV_FILE" -f "`$COMPOSE_FILE" build --progress=plain
docker compose -p "`$COMPOSE_PROJECT_NAME" --env-file "`$ENV_FILE" -f "`$COMPOSE_FILE" up -d --no-build
fi
docker compose -p "`$COMPOSE_PROJECT_NAME" --env-file "`$ENV_FILE" -f "`$COMPOSE_FILE" ps
"@
if ($deploy.exit_code -ne 0) {
Add-Check "remote_deploy" "Remote compose deploy" "fail" "Remote compose deploy failed." $true @{ log = $deploy.log }
throw "Remote compose deploy failed. See $($deploy.log)"
}
Add-Check "remote_deploy" "Remote compose deploy" "pass" "Compose started on independent smoke ports." $true @{ log = $deploy.log }
$health = Invoke-Remote "remote-health" @"
set -euo pipefail
for i in `$(seq 1 60); do
if curl -fsS http://127.0.0.1:$ContractApiHostPort/health >/tmp/v2-contract-health.json &&
curl -fsS http://127.0.0.1:$AiPlatformApiHostPort/health >/tmp/v2-ai-health.json &&
curl -fsS http://127.0.0.1:$ContractWebHostPort/health >/tmp/v2-web-health.json; then
cat /tmp/v2-contract-health.json
echo
cat /tmp/v2-ai-health.json
echo
cat /tmp/v2-web-health.json
exit 0
fi
sleep 5
done
docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) ps
docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) logs --tail=160
exit 1
"@
if ($health.exit_code -ne 0) {
Add-Check "remote_health" "Remote health checks" "fail" "Health checks failed." $true @{ log = $health.log }
throw "Remote health checks failed. See $($health.log)"
}
Add-Check "remote_health" "Remote health checks" "pass" "Web, Contract API, and AI Platform health endpoints are reachable." $true @{ log = $health.log }
$goldenGate = Invoke-Remote "remote-ai-golden-sample-gate" @"
set -euo pipefail
cd $(ConvertTo-ShellSingleQuote $RemoteProjectPath)
docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T ai-platform-api python -m yuqei_ai_platform_api.golden_sample_gate --format json < /dev/null
"@
if ($goldenGate.exit_code -ne 0 -or $goldenGate.output -notmatch '"status": "passed"' -or $goldenGate.output -notmatch '"total_samples": 90') {
Add-Check "ai_golden_sample_gate" "AI golden sample asset gate" "fail" "Golden sample asset gate failed." $true @{ log = $goldenGate.log }
throw "AI golden sample asset gate failed. See $($goldenGate.log)"
}
Add-Check "ai_golden_sample_gate" "AI golden sample asset gate" "pass" "Packaged golden assets passed: 90 samples, 6 suites, 16 checks." $true @{ log = $goldenGate.log }
$dbCheck = Invoke-Remote "remote-db-check" @"
set -euo pipefail
docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T postgres psql -U yuqei -d postgres -tAc "SELECT datname FROM pg_database WHERE datname IN ('yuqei_contract','yuqei_ai','yuqei_ops') ORDER BY datname;"
"@
if ($dbCheck.exit_code -ne 0 -or $dbCheck.output -notmatch "yuqei_contract" -or $dbCheck.output -notmatch "yuqei_ai" -or $dbCheck.output -notmatch "yuqei_ops") {
Add-Check "remote_db_check" "Remote database baseline" "fail" "Expected V2 databases were not found." $true @{ log = $dbCheck.log }
throw "Remote database baseline failed. See $($dbCheck.log)"
}
Add-Check "remote_db_check" "Remote database baseline" "pass" "yuqei_contract, yuqei_ai, and yuqei_ops exist." $true @{ log = $dbCheck.log }
if ($SkipSmoke) {
Add-Check "review_worker_smoke" "Contract review worker smoke" "skip" "Skipped by -SkipSmoke." $false
} else {
$smoke = Invoke-Remote "remote-review-worker-smoke" @"
set -euo pipefail
cd $(ConvertTo-ShellSingleQuote $RemoteProjectPath)
docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T postgres psql -U yuqei -d yuqei_contract -v ON_ERROR_STOP=1 -c "UPDATE review_tasks SET status = 'failed', status_label = 'smoke cleanup', error_message = 'Skipped before deterministic smoke verification.', completed_at = now(), updated_at = now() WHERE status IN ('queued', 'running');" < /dev/null
CREATE_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin' -d '{"title":"Smoke Review Contract","counterparty":"Smoke Counterparty","source_text":"Smoke contract body. The parties agree on payment, delivery, acceptance, confidentiality, breach liability, dispute resolution, and evidence archive obligations.","owner":"smoke"}')
SMOKE_CONTRACT_ID=`$(printf '%s' "`$CREATE_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; print(json.load(sys.stdin)["id"])')
TASK_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin' -d '{"actor_name":"smoke","comment":"deterministic review worker smoke"}')
SMOKE_TASK_ID=`$(printf '%s' "`$TASK_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; print(json.load(sys.stdin)["id"])')
SMOKE_OUTPUT=`$(docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-review-worker python -m yuqei_contract_api.review_worker --run-once --limit 1 < /dev/null)
echo "`$SMOKE_OUTPUT"
printf '%s' "`$SMOKE_OUTPUT" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; payload = json.load(sys.stdin); raise SystemExit(1 if payload.get("failed", 0) > 0 else 0)'
for i in `$(seq 1 30); do
TASKS_RESPONSE=`$(curl -fsS http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review-tasks -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin')
TASK_STATUS=`$(printf '%s' "`$TASKS_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T -e SMOKE_TASK_ID="`$SMOKE_TASK_ID" contract-api python -c 'import json, os, sys; task_id = os.environ["SMOKE_TASK_ID"]; rows = json.load(sys.stdin); print(next((item.get("status", "missing") for item in rows if item.get("id") == task_id), "missing"))')
echo "smoke_review_task_status=`$TASK_STATUS"
if [ "`$TASK_STATUS" = "succeeded" ]; then
break
fi
if [ "`$TASK_STATUS" = "failed" ]; then
echo "`$TASKS_RESPONSE"
exit 1
fi
sleep 2
done
if [ "`$TASK_STATUS" != "succeeded" ]; then
echo "`$TASKS_RESPONSE"
exit 1
fi
TASK_DETAIL=`$(printf '%s' "`$TASKS_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T -e SMOKE_TASK_ID="`$SMOKE_TASK_ID" contract-api python -c 'import json, os, sys; task_id = os.environ["SMOKE_TASK_ID"]; rows = json.load(sys.stdin); print(json.dumps(next(item for item in rows if item.get("id") == task_id), ensure_ascii=False))')
echo "`$TASK_DETAIL"
printf '%s' "`$TASK_DETAIL" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; task = json.load(sys.stdin); assert task.get("ai_audit_run_id"); assert task.get("risk_items") is not None'
curl -fsS http://127.0.0.1:$ContractApiHostPort/health >/dev/null
"@
if ($smoke.exit_code -ne 0) {
Add-Check "review_worker_smoke" "Contract review worker smoke" "fail" "Review worker smoke failed." $true @{ log = $smoke.log }
throw "Review worker smoke failed. See $($smoke.log)"
}
Add-Check "review_worker_smoke" "Contract review worker smoke" "pass" "Worker run-once command completed." $true @{ log = $smoke.log }
}
if (-not $KeepRemoteArchive) {
$cleanup = Invoke-Remote "remote-cleanup-archive" "rm -f $(ConvertTo-ShellSingleQuote $remoteArchive)"
if ($cleanup.exit_code -eq 0) {
Add-Check "cleanup_archive" "Cleanup remote archive" "pass" "Remote archive removed." $false @{ log = $cleanup.log }
} else {
Add-Check "cleanup_archive" "Cleanup remote archive" "warn" "Unable to remove remote archive." $false @{ log = $cleanup.log }
}
}
Write-Summary "pass"
Write-Host "V2_SMOKE_DOCKER_DEPLOY_PASS $artifactDir"
} catch {
Add-Check "fatal" "Fatal error" "fail" $_.Exception.Message $true
Write-Summary "fail"
Write-Host "V2_SMOKE_DOCKER_DEPLOY_FAIL $artifactDir"
throw
} finally {
Pop-Location
}