520 lines
15 KiB
PowerShell
520 lines
15 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[Alias("h")]
|
|
[switch]$Help,
|
|
[string]$ApiHost = "127.0.0.1",
|
|
[int]$Port = 3310,
|
|
[string]$Log = "warn,tower_http=warn",
|
|
[int]$StartupTimeoutSeconds = 30,
|
|
[string]$LegacyPrefix = "/generated-character-drafts/*",
|
|
[string[]]$PathSegments = @("oss-smoke"),
|
|
[string]$FileName = "tmp_oss_upload_test.txt",
|
|
[string]$FileContent = "Genarrative OSS smoke test",
|
|
[switch]$KeepObject,
|
|
[switch]$JsonOnly
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
function Write-Usage {
|
|
@(
|
|
'Usage:'
|
|
' ./server-rs/scripts/oss-smoke.ps1'
|
|
' ./server-rs/scripts/oss-smoke.ps1 -LegacyPrefix "/generated-characters/*" -PathSegments hero_001,visual,asset_01'
|
|
' ./server-rs/scripts/oss-smoke.ps1 -KeepObject'
|
|
''
|
|
'Notes:'
|
|
' 1. Load OSS config from repository root .env and .env.local'
|
|
' 2. Start a temporary local api-server process'
|
|
' 3. Request /api/assets/direct-upload-tickets and perform a real PostObject upload'
|
|
' 4. Verify bucket access, uploaded object visibility, and delete the test object by default'
|
|
) -join [Environment]::NewLine
|
|
}
|
|
|
|
function Assert-Condition {
|
|
param(
|
|
[bool]$Condition,
|
|
[string]$Message
|
|
)
|
|
|
|
if (-not $Condition) {
|
|
throw $Message
|
|
}
|
|
}
|
|
|
|
function Read-EnvFile {
|
|
param(
|
|
[string]$Path,
|
|
[hashtable]$Target
|
|
)
|
|
|
|
if (-not (Test-Path $Path)) {
|
|
return
|
|
}
|
|
|
|
foreach ($line in Get-Content -Encoding UTF8 $Path) {
|
|
$trimmed = $line.Trim()
|
|
if ([string]::IsNullOrWhiteSpace($trimmed) -or $trimmed.StartsWith('#')) {
|
|
continue
|
|
}
|
|
|
|
$separatorIndex = $trimmed.IndexOf('=')
|
|
if ($separatorIndex -lt 1) {
|
|
continue
|
|
}
|
|
|
|
$key = $trimmed.Substring(0, $separatorIndex).Trim()
|
|
$value = $trimmed.Substring($separatorIndex + 1).Trim()
|
|
|
|
if ($value.Length -ge 2 -and $value.StartsWith('"') -and $value.EndsWith('"')) {
|
|
$value = $value.Substring(1, $value.Length - 2)
|
|
}
|
|
|
|
$Target[$key] = $value
|
|
}
|
|
}
|
|
|
|
function Set-InheritedEnvVar {
|
|
param(
|
|
[string]$Name,
|
|
[string]$Value,
|
|
[hashtable]$Snapshot
|
|
)
|
|
|
|
if (-not $Snapshot.ContainsKey($Name)) {
|
|
$Snapshot[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process")
|
|
}
|
|
|
|
[Environment]::SetEnvironmentVariable($Name, $Value, "Process")
|
|
}
|
|
|
|
function Restore-InheritedEnvVars {
|
|
param([hashtable]$Snapshot)
|
|
|
|
foreach ($entry in $Snapshot.GetEnumerator()) {
|
|
[Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process")
|
|
}
|
|
}
|
|
|
|
function ConvertTo-Rfc1123Date {
|
|
return [DateTime]::UtcNow.ToString("r", [System.Globalization.CultureInfo]::InvariantCulture)
|
|
}
|
|
|
|
function New-HmacSha1Signature {
|
|
param(
|
|
[string]$Secret,
|
|
[string]$Content
|
|
)
|
|
|
|
$hmac = New-Object System.Security.Cryptography.HMACSHA1
|
|
try {
|
|
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
|
|
$hashBytes = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Content))
|
|
return [Convert]::ToBase64String($hashBytes)
|
|
}
|
|
finally {
|
|
$hmac.Dispose()
|
|
}
|
|
}
|
|
|
|
function Invoke-SignedOssRequest {
|
|
param(
|
|
[string]$Method,
|
|
[string]$Bucket,
|
|
[string]$Endpoint,
|
|
[string]$AccessKeyId,
|
|
[string]$AccessKeySecret,
|
|
[string]$ObjectKey = ""
|
|
)
|
|
|
|
$dateValue = ConvertTo-Rfc1123Date
|
|
$canonicalResource = if ([string]::IsNullOrWhiteSpace($ObjectKey)) {
|
|
"/$Bucket/"
|
|
}
|
|
else {
|
|
"/$Bucket/$ObjectKey"
|
|
}
|
|
$stringToSign = "$Method`n`n`n$dateValue`n$canonicalResource"
|
|
$signature = New-HmacSha1Signature -Secret $AccessKeySecret -Content $stringToSign
|
|
$uri = if ([string]::IsNullOrWhiteSpace($ObjectKey)) {
|
|
"https://$Bucket.$Endpoint/"
|
|
}
|
|
else {
|
|
"https://$Bucket.$Endpoint/$ObjectKey"
|
|
}
|
|
|
|
try {
|
|
$response = Invoke-WebRequest `
|
|
-Uri $uri `
|
|
-Method $Method `
|
|
-Headers @{
|
|
"Date" = $dateValue
|
|
"Authorization" = "OSS $AccessKeyId`:$signature"
|
|
} `
|
|
-UseBasicParsing `
|
|
-TimeoutSec 30
|
|
|
|
return @{
|
|
ok = $true
|
|
status = [int]$response.StatusCode
|
|
url = $uri
|
|
}
|
|
}
|
|
catch {
|
|
$statusCode = $null
|
|
$body = $_.Exception.Message
|
|
|
|
if ($_.Exception.Response) {
|
|
try {
|
|
$statusCode = [int]$_.Exception.Response.StatusCode
|
|
}
|
|
catch {
|
|
$statusCode = $null
|
|
}
|
|
}
|
|
|
|
return @{
|
|
ok = $false
|
|
status = $statusCode
|
|
url = $uri
|
|
body = $body
|
|
}
|
|
}
|
|
}
|
|
|
|
function Wait-ForHealthz {
|
|
param(
|
|
[string]$Uri,
|
|
[int]$TimeoutSeconds,
|
|
$Process
|
|
)
|
|
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
$lastError = $null
|
|
|
|
while ((Get-Date) -lt $deadline) {
|
|
if ($Process.HasExited) {
|
|
throw "api-server exited before /healthz became ready."
|
|
}
|
|
|
|
try {
|
|
$response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 2
|
|
if ($response.StatusCode -eq 200) {
|
|
return
|
|
}
|
|
|
|
$lastError = "Unexpected status code $($response.StatusCode)"
|
|
}
|
|
catch {
|
|
$lastError = $_.Exception.Message
|
|
}
|
|
|
|
Start-Sleep -Milliseconds 300
|
|
}
|
|
|
|
throw "Timed out waiting for /healthz readiness. Last error: $lastError"
|
|
}
|
|
|
|
function Invoke-JsonPost {
|
|
param(
|
|
[string]$Uri,
|
|
[string]$Body
|
|
)
|
|
|
|
$response = Invoke-WebRequest `
|
|
-Uri $Uri `
|
|
-Method Post `
|
|
-ContentType "application/json" `
|
|
-Headers @{ "x-request-id" = "oss-smoke-$([guid]::NewGuid().ToString('N').Substring(0, 8))" } `
|
|
-Body $Body `
|
|
-UseBasicParsing `
|
|
-TimeoutSec 30
|
|
|
|
return $response.Content | ConvertFrom-Json
|
|
}
|
|
|
|
function New-MultipartFormData {
|
|
param(
|
|
[hashtable]$Fields,
|
|
[string]$FilePath,
|
|
[string]$FileFieldName = "file"
|
|
)
|
|
|
|
$boundary = "----CodexBoundary$([Guid]::NewGuid().ToString('N'))"
|
|
$encoding = [System.Text.Encoding]::UTF8
|
|
$memory = New-Object System.IO.MemoryStream
|
|
|
|
try {
|
|
foreach ($entry in $Fields.GetEnumerator()) {
|
|
$prefix = "--$boundary`r`nContent-Disposition: form-data; name=""$($entry.Key)""`r`n`r`n$($entry.Value)`r`n"
|
|
$bytes = $encoding.GetBytes($prefix)
|
|
$memory.Write($bytes, 0, $bytes.Length)
|
|
}
|
|
|
|
$fileInfo = Get-Item -LiteralPath $FilePath
|
|
$mimeType = "application/octet-stream"
|
|
if ($fileInfo.Extension -ieq ".txt") {
|
|
$mimeType = "text/plain"
|
|
}
|
|
|
|
$fileHeader = "--$boundary`r`nContent-Disposition: form-data; name=""$FileFieldName""; filename=""$($fileInfo.Name)""`r`nContent-Type: $mimeType`r`n`r`n"
|
|
$fileHeaderBytes = $encoding.GetBytes($fileHeader)
|
|
$memory.Write($fileHeaderBytes, 0, $fileHeaderBytes.Length)
|
|
|
|
$fileBytes = [System.IO.File]::ReadAllBytes($FilePath)
|
|
$memory.Write($fileBytes, 0, $fileBytes.Length)
|
|
|
|
$fileFooterBytes = $encoding.GetBytes("`r`n--$boundary--`r`n")
|
|
$memory.Write($fileFooterBytes, 0, $fileFooterBytes.Length)
|
|
|
|
return @{
|
|
Boundary = $boundary
|
|
Body = $memory.ToArray()
|
|
}
|
|
}
|
|
finally {
|
|
$memory.Dispose()
|
|
}
|
|
}
|
|
|
|
function Remove-IfExists {
|
|
param([string]$Path)
|
|
|
|
if (Test-Path $Path) {
|
|
Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
if ($Help) {
|
|
Write-Usage
|
|
exit 0
|
|
}
|
|
|
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
$serverRsDir = Split-Path -Parent $scriptDir
|
|
$repoRoot = Split-Path -Parent $serverRsDir
|
|
$manifestPath = Join-Path $serverRsDir "Cargo.toml"
|
|
$binaryPath = Join-Path $serverRsDir "target\debug\api-server.exe"
|
|
$baseUrl = "http://$ApiHost`:$Port"
|
|
$healthzUrl = "$baseUrl/healthz"
|
|
$runId = [Guid]::NewGuid().ToString("N")
|
|
$stdoutLog = Join-Path $env:TEMP "genarrative-server-rs-oss-smoke-$runId.stdout.log"
|
|
$stderrLog = Join-Path $env:TEMP "genarrative-server-rs-oss-smoke-$runId.stderr.log"
|
|
$uploadFilePath = Join-Path $env:TEMP "$runId-$FileName"
|
|
$envSnapshot = @{}
|
|
$mergedEnv = @{}
|
|
$serverProcess = $null
|
|
$deleteResult = $null
|
|
$signedObjectHead = $null
|
|
|
|
if (-not (Test-Path $manifestPath)) {
|
|
throw "Missing server-rs/Cargo.toml, cannot start OSS smoke script."
|
|
}
|
|
|
|
Read-EnvFile -Path (Join-Path $repoRoot ".env") -Target $mergedEnv
|
|
Read-EnvFile -Path (Join-Path $repoRoot ".env.local") -Target $mergedEnv
|
|
|
|
$bucket = [string]($mergedEnv["ALIYUN_OSS_BUCKET"])
|
|
$endpoint = [string]($mergedEnv["ALIYUN_OSS_ENDPOINT"])
|
|
$accessKeyId = [string]($mergedEnv["ALIYUN_OSS_ACCESS_KEY_ID"])
|
|
$accessKeySecret = [string]($mergedEnv["ALIYUN_OSS_ACCESS_KEY_SECRET"])
|
|
|
|
Assert-Condition (-not [string]::IsNullOrWhiteSpace($bucket)) "Missing ALIYUN_OSS_BUCKET in .env/.env.local."
|
|
Assert-Condition (-not [string]::IsNullOrWhiteSpace($endpoint)) "Missing ALIYUN_OSS_ENDPOINT in .env/.env.local."
|
|
Assert-Condition (-not [string]::IsNullOrWhiteSpace($accessKeyId)) "Missing ALIYUN_OSS_ACCESS_KEY_ID in .env/.env.local."
|
|
Assert-Condition (-not [string]::IsNullOrWhiteSpace($accessKeySecret)) "Missing ALIYUN_OSS_ACCESS_KEY_SECRET in .env/.env.local."
|
|
|
|
$bucketHead = Invoke-SignedOssRequest `
|
|
-Method "HEAD" `
|
|
-Bucket $bucket `
|
|
-Endpoint $endpoint `
|
|
-AccessKeyId $accessKeyId `
|
|
-AccessKeySecret $accessKeySecret
|
|
|
|
[System.IO.File]::WriteAllText($uploadFilePath, "$FileContent`n", [System.Text.Encoding]::UTF8)
|
|
|
|
Push-Location $serverRsDir
|
|
try {
|
|
Write-Host "[server-rs:oss-smoke] step: cargo build -p api-server"
|
|
cargo build -p api-server --manifest-path $manifestPath
|
|
|
|
Assert-Condition (Test-Path $binaryPath) "Missing api-server binary at $binaryPath after cargo build."
|
|
|
|
foreach ($entry in $mergedEnv.GetEnumerator()) {
|
|
Set-InheritedEnvVar -Name $entry.Key -Value ([string]$entry.Value) -Snapshot $envSnapshot
|
|
}
|
|
Set-InheritedEnvVar -Name "GENARRATIVE_API_HOST" -Value $ApiHost -Snapshot $envSnapshot
|
|
Set-InheritedEnvVar -Name "GENARRATIVE_API_PORT" -Value "$Port" -Snapshot $envSnapshot
|
|
Set-InheritedEnvVar -Name "GENARRATIVE_API_LOG" -Value $Log -Snapshot $envSnapshot
|
|
|
|
Write-Host "[server-rs:oss-smoke] step: start api-server binary"
|
|
$serverProcess = Start-Process `
|
|
-FilePath $binaryPath `
|
|
-WorkingDirectory $repoRoot `
|
|
-PassThru `
|
|
-RedirectStandardOutput $stdoutLog `
|
|
-RedirectStandardError $stderrLog
|
|
|
|
Restore-InheritedEnvVars -Snapshot $envSnapshot
|
|
|
|
Write-Host "[server-rs:oss-smoke] step: wait for /healthz readiness"
|
|
Wait-ForHealthz -Uri $healthzUrl -TimeoutSeconds $StartupTimeoutSeconds -Process $serverProcess
|
|
|
|
$timestampSegment = Get-Date -Format "yyyyMMdd-HHmmss"
|
|
$resolvedPathSegments = @($PathSegments + $timestampSegment)
|
|
$ticketRequestBody = @{
|
|
legacyPrefix = $LegacyPrefix
|
|
pathSegments = $resolvedPathSegments
|
|
fileName = $FileName
|
|
contentType = "text/plain"
|
|
metadata = @{
|
|
origin = "server-rs-oss-smoke"
|
|
"asset-kind" = "manual-test"
|
|
}
|
|
} | ConvertTo-Json -Depth 5
|
|
|
|
Write-Host "[server-rs:oss-smoke] step: request direct upload ticket"
|
|
$ticketEnvelope = Invoke-JsonPost -Uri "$baseUrl/api/assets/direct-upload-tickets" -Body $ticketRequestBody
|
|
$upload = $ticketEnvelope.upload
|
|
if ($null -eq $upload) {
|
|
$upload = $ticketEnvelope.data.upload
|
|
}
|
|
Assert-Condition ($null -ne $upload) "OSS direct upload ticket response is missing upload payload."
|
|
|
|
$formFields = @{}
|
|
$upload.formFields.psobject.Properties | ForEach-Object {
|
|
$formFields[$_.Name] = [string]$_.Value
|
|
}
|
|
|
|
$multipart = New-MultipartFormData -Fields $formFields -FilePath $uploadFilePath
|
|
|
|
Write-Host "[server-rs:oss-smoke] step: upload test object to OSS"
|
|
$uploadResponse = $null
|
|
try {
|
|
$uploadResponse = Invoke-WebRequest `
|
|
-Uri $upload.host `
|
|
-Method Post `
|
|
-ContentType "multipart/form-data; boundary=$($multipart.Boundary)" `
|
|
-Body $multipart.Body `
|
|
-UseBasicParsing `
|
|
-TimeoutSec 60
|
|
|
|
$uploadResult = @{
|
|
ok = $true
|
|
status = [int]$uploadResponse.StatusCode
|
|
body = $uploadResponse.Content
|
|
}
|
|
}
|
|
catch {
|
|
$statusCode = $null
|
|
if ($_.Exception.Response) {
|
|
try {
|
|
$statusCode = [int]$_.Exception.Response.StatusCode
|
|
}
|
|
catch {
|
|
$statusCode = $null
|
|
}
|
|
}
|
|
|
|
$uploadResult = @{
|
|
ok = $false
|
|
status = $statusCode
|
|
body = $_.Exception.Message
|
|
}
|
|
}
|
|
|
|
$publicHead = $null
|
|
if (-not [string]::IsNullOrWhiteSpace([string]$upload.publicUrl)) {
|
|
try {
|
|
$publicResponse = Invoke-WebRequest `
|
|
-Uri ([string]$upload.publicUrl) `
|
|
-Method Head `
|
|
-UseBasicParsing `
|
|
-TimeoutSec 30
|
|
|
|
$publicHead = @{
|
|
ok = $true
|
|
status = [int]$publicResponse.StatusCode
|
|
url = [string]$upload.publicUrl
|
|
}
|
|
}
|
|
catch {
|
|
$statusCode = $null
|
|
if ($_.Exception.Response) {
|
|
try {
|
|
$statusCode = [int]$_.Exception.Response.StatusCode
|
|
}
|
|
catch {
|
|
$statusCode = $null
|
|
}
|
|
}
|
|
|
|
$publicHead = @{
|
|
ok = $false
|
|
status = $statusCode
|
|
url = [string]$upload.publicUrl
|
|
body = $_.Exception.Message
|
|
}
|
|
}
|
|
}
|
|
|
|
$signedObjectHead = Invoke-SignedOssRequest `
|
|
-Method "HEAD" `
|
|
-Bucket $bucket `
|
|
-Endpoint $endpoint `
|
|
-AccessKeyId $accessKeyId `
|
|
-AccessKeySecret $accessKeySecret `
|
|
-ObjectKey ([string]$upload.objectKey)
|
|
|
|
if (-not $KeepObject) {
|
|
$deleteResult = Invoke-SignedOssRequest `
|
|
-Method "DELETE" `
|
|
-Bucket $bucket `
|
|
-Endpoint $endpoint `
|
|
-AccessKeyId $accessKeyId `
|
|
-AccessKeySecret $accessKeySecret `
|
|
-ObjectKey ([string]$upload.objectKey)
|
|
}
|
|
|
|
$result = [ordered]@{
|
|
bucketHead = $bucketHead
|
|
ticket = [ordered]@{
|
|
host = [string]$upload.host
|
|
objectKey = [string]$upload.objectKey
|
|
legacyPublicPath = [string]$upload.legacyPublicPath
|
|
publicUrl = if ([string]::IsNullOrWhiteSpace([string]$upload.publicUrl)) {
|
|
$null
|
|
}
|
|
else {
|
|
[string]$upload.publicUrl
|
|
}
|
|
}
|
|
upload = $uploadResult
|
|
publicHead = $publicHead
|
|
signedObjectHead = $signedObjectHead
|
|
delete = $deleteResult
|
|
}
|
|
|
|
if ($JsonOnly) {
|
|
$result | ConvertTo-Json -Depth 8
|
|
}
|
|
else {
|
|
Write-Host "[server-rs:oss-smoke] result:"
|
|
$result | ConvertTo-Json -Depth 8
|
|
}
|
|
}
|
|
finally {
|
|
Restore-InheritedEnvVars -Snapshot $envSnapshot
|
|
|
|
if ($null -ne $serverProcess -and -not $serverProcess.HasExited) {
|
|
Stop-Process -Id $serverProcess.Id -Force
|
|
$serverProcess.WaitForExit()
|
|
}
|
|
|
|
Pop-Location
|
|
|
|
Remove-IfExists -Path $uploadFilePath
|
|
Remove-IfExists -Path $stdoutLog
|
|
Remove-IfExists -Path $stderrLog
|
|
}
|