param( [string]$ServerHost = "192.168.3.90", [string]$SshUser = "root", [string]$SshKeyPath = "$HOME\.ssh\water_audit_ed25519", [string]$RemoteProjectPath = "/opt/ai-contract-platform", [string]$ComposeFile = "docker-compose.server.yml", [string]$SharedAiSourcePath = "D:\shared-ai-platform", [string]$BackendSourcePath = "", [string]$RemoteServicePath = "/opt/ai-contract-platform/shared-ai-platform", [string]$ArtifactRoot = "", [switch]$AllowDirtyWorktree ) $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss" if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { $ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\shared-ai-platform-docker-deploy" } $artifactDir = Join-Path $ArtifactRoot $timestamp $summaryJson = Join-Path $artifactDir "shared-ai-platform-docker-deploy-summary.json" $summaryMd = Join-Path $artifactDir "shared-ai-platform-docker-deploy-summary.md" $gitStatusPath = Join-Path $artifactDir "git-status.txt" $archiveName = "shared-ai-platform-$timestamp.tgz" $backendArchiveName = "ai-contract-backend-$timestamp.tgz" $composeArchiveName = "docker-compose.server-$timestamp.yml" $localArchive = Join-Path $artifactDir $archiveName $localBackendArchive = Join-Path $artifactDir $backendArchiveName $localComposeCopy = Join-Path $artifactDir $composeArchiveName $remoteArchive = "/tmp/$archiveName" $remoteBackendArchive = "/tmp/$backendArchiveName" $remoteComposeCopy = "/tmp/$composeArchiveName" $localRemoteEnv = Join-Path $artifactDir "shared-ai-platform.remote.env" $remoteEnv = "/tmp/shared-ai-platform.env" if ([string]::IsNullOrWhiteSpace($BackendSourcePath)) { $BackendSourcePath = Join-Path $repoRoot "backend" } New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null $checks = New-Object System.Collections.Generic.List[object] 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 12 | Set-Content -Encoding UTF8 -Path $Path } function Invoke-Remote { param( [string]$Name, [string]$Command ) $target = "${SshUser}@${ServerHost}" $logPath = Join-Path $artifactDir "$Name.log" $args = @( "-i", $SshKeyPath, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $target, $Command ) $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $output = & ssh @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 output = ($output -join [Environment]::NewLine) } } function Invoke-ScpToRemote { param( [string]$LocalPath, [string]$RemotePath, [string]$LogName ) $target = "${SshUser}@${ServerHost}:$RemotePath" $logPath = Join-Path $artifactDir $LogName $args = @( "-i", $SshKeyPath, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $LocalPath, $target ) $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $output = & scp @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 Get-EnvMap { param([string]$Path) $data = @{} foreach ($line in Get-Content $Path) { $trimmed = $line.Trim() if (-not $trimmed -or $trimmed.StartsWith("#")) { continue } $parts = $trimmed -split "=", 2 if ($parts.Length -eq 2) { $data[$parts[0].Trim()] = $parts[1] } } return $data } if (-not (Test-Path "$HOME\.Codex\skills\gstack\bin")) { Add-Check -Key "gstack" -Label "gstack preflight" -Status "fail" -Detail "gstack is missing" } else { Add-Check -Key "gstack" -Label "gstack preflight" -Status "pass" -Detail "GSTACK_OK" } if (-not (Test-Path $SshKeyPath)) { Add-Check -Key "ssh_key" -Label "SSH key" -Status "fail" -Detail "SSH key not found: $SshKeyPath" } else { Add-Check -Key "ssh_key" -Label "SSH key" -Status "pass" -Detail "SSH key exists" } if (-not (Test-Path $SharedAiSourcePath)) { Add-Check -Key "shared_source" -Label "shared-ai source" -Status "fail" -Detail "shared-ai-platform source path not found: $SharedAiSourcePath" } else { Add-Check -Key "shared_source" -Label "shared-ai source" -Status "pass" -Detail "shared-ai-platform source path exists" } if (-not (Test-Path $BackendSourcePath)) { Add-Check -Key "backend_source" -Label "backend source" -Status "fail" -Detail "backend source path not found: $BackendSourcePath" } else { Add-Check -Key "backend_source" -Label "backend source" -Status "pass" -Detail "backend source path exists" } $sharedEnvPath = Join-Path $SharedAiSourcePath ".env" if (-not (Test-Path $sharedEnvPath)) { Add-Check -Key "shared_env" -Label "shared-ai env" -Status "fail" -Detail "shared-ai-platform .env not found: $sharedEnvPath" } else { $sharedEnv = Get-EnvMap -Path $sharedEnvPath $requiredSharedVars = @("SHARED_AI_OPENAI_BASE_URL", "SHARED_AI_OPENAI_API_KEY", "SHARED_AI_OPENAI_MODEL") $missingSharedVars = @($requiredSharedVars | Where-Object { -not $sharedEnv.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($sharedEnv[$_]) }) if ($missingSharedVars.Count -gt 0) { Add-Check -Key "shared_env" -Label "shared-ai env" -Status "fail" -Detail "Missing required shared-ai env vars: $($missingSharedVars -join ', ')" } else { $remoteEnvContent = @( "APP_NAME=Shared AI Platform" "APP_ENV=production" "API_V1_PREFIX=/api/v0.1" "DATABASE_URL=sqlite:////app/storage/shared_ai_platform.db" "SHARED_AI_PLATFORM_API_KEY=$($sharedEnv['SHARED_AI_PLATFORM_API_KEY'])" "SHARED_AI_DEFAULT_PROVIDER=openai_compatible" "SHARED_AI_DIFY_BASE_URL=" "SHARED_AI_DIFY_API_KEY=" "SHARED_AI_DIFY_WORKFLOW_API_KEY=" "SHARED_AI_DIFY_CHAT_API_KEY=" "SHARED_AI_OPENAI_BASE_URL=$($sharedEnv['SHARED_AI_OPENAI_BASE_URL'])" "SHARED_AI_OPENAI_API_KEY=$($sharedEnv['SHARED_AI_OPENAI_API_KEY'])" "SHARED_AI_OPENAI_MODEL=$($sharedEnv['SHARED_AI_OPENAI_MODEL'])" "SHARED_AI_OPENAI_TEMPERATURE=$($sharedEnv['SHARED_AI_OPENAI_TEMPERATURE'])" "SHARED_AI_OPENAI_MAX_TOKENS=$($sharedEnv['SHARED_AI_OPENAI_MAX_TOKENS'])" "SHARED_AI_OPENAI_TIMEOUT_SECONDS=$($sharedEnv['SHARED_AI_OPENAI_TIMEOUT_SECONDS'])" ) -join [Environment]::NewLine $remoteEnvContent | Set-Content -Encoding UTF8 -Path $localRemoteEnv Add-Check -Key "shared_env" -Label "shared-ai env" -Status "pass" -Detail "shared-ai-platform env loaded and remote env artifact generated" -Meta @{ artifact = $localRemoteEnv } } } $gitStatus = git -C $repoRoot status --short ($gitStatus -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $gitStatusPath if ($gitStatus) { if ($AllowDirtyWorktree) { Add-Check -Key "working_tree" -Label "working tree" -Status "warn" -Required $false -Detail "Working tree has uncommitted changes; deployment allowed by -AllowDirtyWorktree" -Meta @{ artifact = $gitStatusPath } } else { Add-Check -Key "working_tree" -Label "working tree" -Status "fail" -Detail "Working tree has uncommitted changes; pass -AllowDirtyWorktree for a controlled test deploy" -Meta @{ artifact = $gitStatusPath } } } else { Add-Check -Key "working_tree" -Label "working tree" -Status "pass" -Detail "Working tree is clean" -Meta @{ artifact = $gitStatusPath } } $failedPreflight = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) if ($failedPreflight.Count -eq 0) { Copy-Item 'D:\ai-contract-platform\docker-compose.server.yml' $localComposeCopy -Force Push-Location (Split-Path $SharedAiSourcePath -Parent) try { & tar -czf $localArchive --exclude=.env --exclude=.pytest_cache --exclude=__pycache__ --exclude=storage (Split-Path $SharedAiSourcePath -Leaf) } finally { Pop-Location } Push-Location (Split-Path $BackendSourcePath -Parent) try { & tar -czf $localBackendArchive --exclude=.env --exclude=.venv --exclude=.pytest_cache --exclude=__pycache__ --exclude=storage (Split-Path $BackendSourcePath -Leaf) } finally { Pop-Location } if ($LASTEXITCODE -eq 0 -and (Test-Path $localArchive) -and (Test-Path $localBackendArchive)) { Add-Check -Key "archive" -Label "deployment archives" -Status "pass" -Detail "shared-ai and backend archives created" -Meta @{ shared_archive = $localArchive; backend_archive = $localBackendArchive } } else { Add-Check -Key "archive" -Label "deployment archives" -Status "fail" -Detail "Archive creation failed" } } $failedBeforeUpload = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) if ($failedBeforeUpload.Count -eq 0) { $uploadArchive = Invoke-ScpToRemote -LocalPath $localArchive -RemotePath $remoteArchive -LogName "upload-shared-ai-archive.log" $uploadBackendArchive = Invoke-ScpToRemote -LocalPath $localBackendArchive -RemotePath $remoteBackendArchive -LogName "upload-backend-archive.log" $uploadCompose = Invoke-ScpToRemote -LocalPath $localComposeCopy -RemotePath $remoteComposeCopy -LogName "upload-shared-ai-compose.log" $uploadEnv = Invoke-ScpToRemote -LocalPath $localRemoteEnv -RemotePath $remoteEnv -LogName "upload-shared-ai-env.log" if ($uploadArchive.exit_code -eq 0 -and $uploadBackendArchive.exit_code -eq 0 -and $uploadCompose.exit_code -eq 0 -and $uploadEnv.exit_code -eq 0) { Add-Check -Key "upload" -Label "upload deployment artifacts" -Status "pass" -Detail "Archive, compose and env uploaded" } else { Add-Check -Key "upload" -Label "upload deployment artifacts" -Status "fail" -Detail "Artifact upload failed" } } $failedBeforeRemote = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) if ($failedBeforeRemote.Count -eq 0) { $remoteScript = @" set -euo pipefail project='$RemoteProjectPath' service_dir='$RemoteServicePath' archive='$remoteArchive' backend_archive='$remoteBackendArchive' compose_tmp='$remoteComposeCopy' remote_env='$remoteEnv' compose='$ComposeFile' stamp='$timestamp' cd "`$project" test -f "`$archive" test -f "`$backend_archive" test -f "`$compose_tmp" test -f "`$remote_env" mkdir -p "`$service_dir" backup_root="`$project/backups/shared-ai-platform" mkdir -p "`$backup_root" if [ -d "`$service_dir" ]; then tar -czf "`$backup_root/shared-ai-platform-before-`$stamp.tgz" -C "`$project" shared-ai-platform || true fi rm -rf "`$service_dir" mkdir -p "`$service_dir" tar -xzf "`$archive" --strip-components=1 -C "`$service_dir" mv "`$remote_env" "`$service_dir/.env" chmod 600 "`$service_dir/.env" backend_backup_root="`$project/backups/backend-shared-ai-rollout" mkdir -p "`$backend_backup_root" if [ -d "`$project/backend" ]; then tar -czf "`$backend_backup_root/backend-before-`$stamp.tgz" -C "`$project" backend || true fi rm -rf "`$project/backend" mkdir -p "`$project/backend" tar -xzf "`$backend_archive" --strip-components=1 -C "`$project/backend" cp "`$compose_tmp" "`$project/`$compose" docker compose -f "`$compose" build shared-ai-platform backend docker compose -f "`$compose" up -d shared-ai-platform backend docker compose -f "`$compose" ps shared-ai-platform backend frontend "@ $remoteDeploy = Invoke-Remote -Name "remote-shared-ai-docker-deploy" -Command $remoteScript if ($remoteDeploy.exit_code -eq 0) { Add-Check -Key "remote_deploy" -Label "remote docker deploy" -Status "pass" -Detail "shared-ai-platform and backend rebuilt/restarted on remote host" -Meta @{ log = $remoteDeploy.log } } else { Add-Check -Key "remote_deploy" -Label "remote docker deploy" -Status "fail" -Detail "Remote deploy failed" -Meta @{ log = $remoteDeploy.log } } } $failedBeforeVerify = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) if ($failedBeforeVerify.Count -eq 0) { Start-Sleep -Seconds 8 $appRegistryLog = Join-Path $artifactDir "shared-ai-app-registry.log" $sharedAiAdminBase = "http://${ServerHost}:8012/api/v0.2" $appRegistryHeaders = @{ Authorization = "Bearer $($sharedEnv['SHARED_AI_PLATFORM_API_KEY'])" "Content-Type" = "application/json" } $appRegistryPayload = @{ app_id = "ai-contract-platform" name = "AI Contract Platform" description = "Remote app registration for ai-contract-platform" owner_team = "contract-platform" scopes = @( "conversations.read", "conversations.write", "knowledge.read", "knowledge.write" ) } try { try { $createResponse = Invoke-RestMethod -Method Post -Uri "$sharedAiAdminBase/apps" -Headers $appRegistryHeaders -Body ($appRegistryPayload | ConvertTo-Json -Depth 6) $createResponse | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 -Path $appRegistryLog } catch { $createFailed = $_.Exception if ($createFailed.Response -and [int]$createFailed.Response.StatusCode -ne 409) { throw } "APP_ALREADY_EXISTS" | Set-Content -Encoding UTF8 -Path $appRegistryLog } $scopeReadResponse = Invoke-RestMethod -Method Get -Uri "$sharedAiAdminBase/apps/ai-contract-platform/scopes" -Headers $appRegistryHeaders ($scopeReadResponse | ConvertTo-Json -Depth 6) | Add-Content -Encoding UTF8 -Path $appRegistryLog $existingScopes = @($scopeReadResponse.data.scopes) $missingScopes = @($appRegistryPayload.scopes | Where-Object { $_ -notin $existingScopes }) if ($missingScopes.Count -gt 0) { throw "Shared AI app registry is missing scopes: $($missingScopes -join ', ')" } Add-Check -Key "app_registry" -Label "shared-ai app registry" -Status "pass" -Detail "ai-contract-platform app registration/scopes ensured on remote host" -Meta @{ log = $appRegistryLog } } catch { ($_ | Out-String) | Set-Content -Encoding UTF8 -Path $appRegistryLog Add-Check -Key "app_registry" -Label "shared-ai app registry" -Status "fail" -Detail "Failed to ensure ai-contract-platform app registry/scopes" -Meta @{ log = $appRegistryLog } } $providerStatus = Invoke-Remote -Name "remote-shared-ai-provider-status" -Command "curl -fsS http://127.0.0.1:8012/system/provider-status" if ($providerStatus.exit_code -eq 0 -and $providerStatus.output -match 'openai_compatible') { Add-Check -Key "provider_status" -Label "shared-ai provider status" -Status "pass" -Detail "shared-ai-platform provider-status responded on remote host" -Meta @{ log = $providerStatus.log } } else { Add-Check -Key "provider_status" -Label "shared-ai provider status" -Status "fail" -Detail "shared-ai-platform provider-status probe failed" -Meta @{ log = $providerStatus.log } } $backendHealth = Invoke-Remote -Name "remote-backend-health" -Command "curl -fsS http://127.0.0.1:8001/health" if ($backendHealth.exit_code -eq 0 -and $backendHealth.output -match 'status') { Add-Check -Key "backend_health" -Label "backend health" -Status "pass" -Detail "backend health probe responded on remote host" -Meta @{ log = $backendHealth.log } } else { Add-Check -Key "backend_health" -Label "backend health" -Status "fail" -Detail "backend health probe failed" -Meta @{ log = $backendHealth.log } } $reviewSmokeScript = @" set -euo pipefail cd '$RemoteProjectPath' docker compose -f $ComposeFile exec -T backend python - <<'PY' import json from app.services.review_service import ReviewService service = ReviewService(db=None) class StubRepository: def __init__(self): self.items={} def create(self, obj): self.items[obj.id]=obj; return obj def update(self, obj): self.items[obj.id]=obj; return obj def get_by_id(self, review_id, tenant_id=None, user_id=None): return self.items.get(review_id) service.repository = StubRepository() class StubRuleRepo: def list_active_for_contract_type(self, tenant_id, contract_type): return [] service.review_rule_repository = StubRuleRepo() class StubReviewResultRepository: def __init__(self): self.items={} def create(self, obj): self.items[obj.review_task_id]=obj; return obj def update(self, obj): self.items[obj.review_task_id]=obj; return obj def get_by_review_task_id(self, review_task_id): return self.items.get(review_task_id) service.review_result_repository = StubReviewResultRepository() class StubFileService: pass service.file_service = StubFileService() created = service.create_review_task( tenant_id='tenant-demo-001', user_id='user-admin', review_as='party_a', contract_type='procurement', text='This agreement includes auto-renewal, limitation of liability, payment milestones, confidentiality obligations, and liquidated damages. Please produce a structured legal review result in Chinese.', file_name='inline.txt', ) result = service.get_review_result({'id':'user-admin','tenant_id':'tenant-demo-001'}, created['id']) assert result['risk_summary']['overall'] != 'low' assert result['recommendation'] in {'manual_review', 'proceed_with_revision', 'proceed'} print(json.dumps({'created': created, 'result': result}, ensure_ascii=False)) PY "@ $reviewSmoke = Invoke-Remote -Name "remote-review-smoke" -Command $reviewSmokeScript if ($reviewSmoke.exit_code -eq 0) { Add-Check -Key "review_smoke" -Label "backend shared-ai review smoke" -Status "pass" -Detail "Remote backend can execute review flow against shared-ai-platform" -Meta @{ log = $reviewSmoke.log } } else { Add-Check -Key "review_smoke" -Label "backend shared-ai review smoke" -Status "fail" -Detail "Remote backend review smoke failed" -Meta @{ log = $reviewSmoke.log } } } $failedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) $overallStatus = if ($failedRequired.Count -gt 0) { "FAIL" } else { "PASS" } $summary = [ordered]@{ name = "shared-ai-platform-docker-deploy" status = $overallStatus generated_at = (Get-Date).ToUniversalTime().ToString("o") artifact_dir = $artifactDir target = [ordered]@{ server_host = $ServerHost remote_project_path = $RemoteProjectPath remote_service_path = $RemoteServicePath } checks = $checks } Save-Json -Path $summaryJson -Value $summary $markdown = New-Object System.Collections.Generic.List[string] $markdown.Add("# Shared AI Platform Docker Deploy") | Out-Null $markdown.Add("") | Out-Null $markdown.Add("- Status: $overallStatus") | Out-Null $markdown.Add("- Generated at: $($summary.generated_at)") | Out-Null $markdown.Add("- Server: $ServerHost") | Out-Null $markdown.Add("- Remote path: $RemoteServicePath") | Out-Null $markdown.Add("") | Out-Null $markdown.Add("## Checks") | Out-Null foreach ($check in $checks) { $markdown.Add("- [$($check.status)] $($check.label): $($check.detail)") | Out-Null } $markdown | Set-Content -Encoding UTF8 -Path $summaryMd Write-Output $artifactDir