[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 }