526 lines
26 KiB
PowerShell
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
|
|
}
|