vignette/scripts/initialize-public-runtime-upload-root.ps1
2026-08-29 23:58:33 +09:00

395 lines
13 KiB
PowerShell

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$StableSourceRoot,
[Parameter(Mandatory = $true)]
[string]$UserUploadDir,
[Parameter(Mandatory = $true)]
[string]$ManifestStateDir,
[Parameter(Mandatory = $true)]
[string]$UserUploadWriteFreezePath,
[Parameter(Mandatory = $true)]
[ValidateRange(0, 2147483647)]
[int]$ExpectedReferenceCount,
[Parameter(Mandatory = $true)]
[ValidateRange(1, 2147483647)]
[int]$ExpectedPreservedObjectCount,
[Parameter(Mandatory = $true)]
[ValidateRange(1, 9223372036854775807)]
[long]$ExpectedPreservedTotalSizeBytes,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-f]{64}$")]
[string]$ExpectedPreservedInventorySha256,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string[]]$SourceUploadDir,
[string]$OfflineQuiescenceCaptureBase64 = "",
[ValidatePattern("^$|^[0-9a-f]{40}$")]
[string]$ExpectedOfflineSourceCommit = "",
[ValidatePattern("^$|^[0-9a-f]{40}$")]
[string]$ExpectedOfflineSourceTree = "",
[string]$PythonPath = "",
[string]$HealthUrl = "http://127.0.0.1:8001/health",
[ValidateRange(1, 300)]
[int]$FreezeTimeoutSeconds = 30
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
function Assert-StableInitializerSourceProvenance {
param(
[Parameter(Mandatory = $true)]
[string]$Root
)
$gitTopLevelOutput = @(& git.exe -C $Root rev-parse --show-toplevel)
if ($LASTEXITCODE -ne 0 -or $gitTopLevelOutput.Count -eq 0) {
throw "Upload initializer source is not a Git worktree"
}
$gitTopLevel = (Resolve-Path -LiteralPath ((@($gitTopLevelOutput) -join "").Trim())).Path
if (-not [string]::Equals(
$gitTopLevel,
$Root,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Upload initializer source must be its Git toplevel"
}
$symbolicRefOutput = @(& git.exe -C $Root symbolic-ref -q HEAD)
$symbolicRefExit = $LASTEXITCODE
if ($symbolicRefExit -eq 0 -or $symbolicRefOutput.Count -gt 0) {
throw "Upload initializer source must use a detached HEAD"
}
if ($symbolicRefExit -ne 1) {
throw "Upload initializer could not prove detached HEAD"
}
$statusOutput = @(& git.exe -C $Root status --porcelain=v1 --untracked-files=all)
if ($LASTEXITCODE -ne 0) {
throw "Upload initializer could not prove source cleanliness"
}
if ($statusOutput.Count -gt 0) {
throw "Upload initializer source must be completely clean"
}
foreach ($relativePath in @(
"scripts/initialize-public-runtime-upload-root.ps1",
"scripts/initialize-public-runtime-upload-root.py",
"scripts/public_runtime_database_identity.py",
"scripts/public-runtime-upload-root.ps1",
"apps/api/app/config.py",
"apps/api/app/upload_storage.py"
)) {
$trackedOutput = @(& git.exe -C $Root ls-files --error-unmatch -- $relativePath)
if ($LASTEXITCODE -ne 0 -or $trackedOutput.Count -ne 1) {
throw "Upload initializer source chain must be tracked"
}
}
}
function Resolve-PrivateStateDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$PublicRoot,
[Parameter(Mandatory = $true)]
[string]$GitRoot
)
$driveAbsolute = $Path -match '^[A-Za-z]:[\\/]'
$uncAbsolute = $Path -match '^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)'
if (-not $driveAbsolute -and -not $uncAbsolute) {
throw "Private upload state directory must be absolute"
}
$fullPath = Get-PublicRuntimeCanonicalPath -Path $Path
$filesystemRoot = [System.IO.Path]::GetPathRoot($fullPath)
if ([string]::Equals(
$fullPath.TrimEnd('\', '/'),
$filesystemRoot.TrimEnd('\', '/'),
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Private upload state directory cannot be a filesystem root"
}
if (
(Test-PublicRuntimePathIsSameOrChild -Candidate $fullPath -Parent $PublicRoot) -or
(Test-PublicRuntimePathIsSameOrChild -Candidate $PublicRoot -Parent $fullPath) -or
(Test-PublicRuntimePathIsSameOrChild -Candidate $fullPath -Parent $GitRoot) -or
(Test-PublicRuntimePathIsSameOrChild -Candidate $GitRoot -Parent $fullPath)
) {
throw "Private upload state must be disjoint from public and source roots"
}
Assert-PublicRuntimePathHasNoReparsePoint -Path $fullPath
if (-not (Test-Path -LiteralPath $fullPath)) {
[System.IO.Directory]::CreateDirectory($fullPath) | Out-Null
}
if (-not (Test-Path -LiteralPath $fullPath -PathType Container)) {
throw "Private upload state directory is unavailable"
}
Assert-PublicRuntimePathHasNoReparsePoint -Path $fullPath
return (Resolve-Path -LiteralPath $fullPath).Path
}
function Remove-OwnedWriteFreeze {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$OwnedToken
)
if (-not [System.IO.File]::Exists($Path)) {
return $true
}
try {
$payload = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
if (
$null -eq $payload -or
$payload.schema_version -ne "vignette.public-upload-write-freeze.v1" -or
$payload.token -ne $OwnedToken
) {
return $false
}
[System.IO.File]::Delete($Path)
return -not [System.IO.File]::Exists($Path)
} catch {
return $false
}
}
function Assert-OnlineUploadWritesRecovered {
param(
[Parameter(Mandatory = $true)]
[string]$Uri,
[ValidateRange(1, 300)]
[int]$TimeoutSeconds = 30
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
do {
$health = $null
try {
$health = Invoke-RestMethod `
-Uri $Uri `
-Method Get `
-TimeoutSec 5 `
-UseBasicParsing
} catch {
$health = $null
}
$freeze = $null
if ($null -ne $health) {
$freeze = $health.upload_write_freeze
}
if (
$null -ne $freeze -and
$freeze.capable -eq $true -and
$freeze.active -eq $false -and
$freeze.valid -eq $true -and
[int]$freeze.in_flight -eq 0
) {
return
}
Start-Sleep -Milliseconds 250
} while ((Get-Date) -lt $deadline)
throw "Upload initialization cleanup could not prove write availability"
}
$resolvedStableSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path
$uploadRootContract = Join-Path $resolvedStableSourceRoot "scripts\public-runtime-upload-root.ps1"
$initializerWorker = Join-Path $resolvedStableSourceRoot "scripts\initialize-public-runtime-upload-root.py"
$databaseIdentityHelper = Join-Path $resolvedStableSourceRoot "scripts\public_runtime_database_identity.py"
foreach ($requiredFile in @($uploadRootContract, $initializerWorker, $databaseIdentityHelper)) {
if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) {
throw "Upload initializer prerequisite is unavailable"
}
}
Assert-StableInitializerSourceProvenance -Root $resolvedStableSourceRoot
. $uploadRootContract
$resolvedUserUploadDir = Resolve-PublicRuntimeUploadRoot `
-SourceRoot $resolvedStableSourceRoot `
-UploadRoot $UserUploadDir `
-CreateIfMissing `
-ProbeWritable
$resolvedManifestStateDir = Resolve-PrivateStateDirectory `
-Path $ManifestStateDir `
-PublicRoot $resolvedUserUploadDir `
-GitRoot $resolvedStableSourceRoot
$freezeDriveAbsolute = $UserUploadWriteFreezePath -match '^[A-Za-z]:[\\/]'
$freezeUncAbsolute = $UserUploadWriteFreezePath -match '^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)'
if (-not $freezeDriveAbsolute -and -not $freezeUncAbsolute) {
throw "Upload write-freeze path must be absolute"
}
$fullFreezePath = Get-PublicRuntimeCanonicalPath -Path $UserUploadWriteFreezePath
$freezeParent = [System.IO.Path]::GetDirectoryName($fullFreezePath)
if (-not [string]::Equals(
$freezeParent.TrimEnd('\', '/'),
$resolvedManifestStateDir.TrimEnd('\', '/'),
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Upload write-freeze must be a direct child of private state"
}
Assert-PublicRuntimePathHasNoReparsePoint -Path $fullFreezePath
if ([string]::IsNullOrWhiteSpace($PythonPath)) {
$PythonPath = Join-Path $resolvedStableSourceRoot "apps\api\.venv\Scripts\python.exe"
}
if (-not (Test-Path -LiteralPath $PythonPath -PathType Leaf)) {
throw "Upload initializer Python runtime is unavailable"
}
$resolvedPythonPath = (Resolve-Path -LiteralPath $PythonPath).Path
$resolvedSources = @()
foreach ($sourceRoot in $SourceUploadDir) {
$sourceDriveAbsolute = $sourceRoot -match '^[A-Za-z]:[\\/]'
$sourceUncAbsolute = $sourceRoot -match '^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)'
if (-not $sourceDriveAbsolute -and -not $sourceUncAbsolute) {
throw "Source upload directory must be absolute"
}
Assert-PublicRuntimePathHasNoReparsePoint -Path $sourceRoot
if (-not (Test-Path -LiteralPath $sourceRoot -PathType Container)) {
throw "Source upload directory is unavailable"
}
$resolvedSource = (Resolve-Path -LiteralPath $sourceRoot).Path
if (
(Test-PublicRuntimePathIsSameOrChild -Candidate $resolvedSource -Parent $resolvedUserUploadDir) -or
(Test-PublicRuntimePathIsSameOrChild -Candidate $resolvedUserUploadDir -Parent $resolvedSource) -or
(Test-PublicRuntimePathIsSameOrChild -Candidate $resolvedSource -Parent $resolvedManifestStateDir) -or
(Test-PublicRuntimePathIsSameOrChild -Candidate $resolvedManifestStateDir -Parent $resolvedSource)
) {
throw "Source upload directories must be disjoint from target and private state"
}
$resolvedSources += $resolvedSource
}
$freezeToken = (
[Guid]::NewGuid().ToString("N") +
[Guid]::NewGuid().ToString("N")
)
$freezePayload = @{
schema_version = "vignette.public-upload-write-freeze.v1"
token = $freezeToken
} | ConvertTo-Json -Compress
$freezeBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($freezePayload)
$freezeStream = $null
$freezeOwned = $false
$freezePublished = $false
$offlineQuiescenceMode = -not [string]::IsNullOrWhiteSpace(
$OfflineQuiescenceCaptureBase64
)
$offlineSourcePinsPresent = (
-not [string]::IsNullOrWhiteSpace($ExpectedOfflineSourceCommit) -and
-not [string]::IsNullOrWhiteSpace($ExpectedOfflineSourceTree)
)
if ($offlineQuiescenceMode -ne $offlineSourcePinsPresent) {
throw "Offline quiescence capture and lowercase source commit/tree pins are required together"
}
try {
try {
$freezeStream = [System.IO.File]::Open(
$fullFreezePath,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
$freezeOwned = $true
$freezeStream.Write($freezeBytes, 0, $freezeBytes.Length)
$freezeStream.Flush($true)
$freezePublished = $true
} finally {
if ($null -ne $freezeStream) {
$freezeStream.Dispose()
}
}
$workerArgs = @(
"-X", "utf8", "-B", $initializerWorker,
"--upload-root", $resolvedUserUploadDir,
"--manifest-state-dir", $resolvedManifestStateDir,
"--write-freeze-path", $fullFreezePath,
"--expected-reference-count", $ExpectedReferenceCount.ToString(),
"--expected-preserved-object-count", $ExpectedPreservedObjectCount.ToString(),
"--expected-preserved-total-size-bytes", $ExpectedPreservedTotalSizeBytes.ToString(),
"--expected-preserved-inventory-sha256", $ExpectedPreservedInventorySha256,
"--health-url", $HealthUrl,
"--freeze-timeout-seconds", $FreezeTimeoutSeconds.ToString()
)
foreach ($resolvedSource in $resolvedSources) {
$workerArgs += @("--source-root", $resolvedSource)
}
if ($offlineQuiescenceMode) {
if ($OfflineQuiescenceCaptureBase64 -notmatch '^[A-Za-z0-9+/]+={0,2}$') {
throw "Offline quiescence capture is not canonical base64"
}
$workerArgs += @(
"--offline-quiescence-capture-base64",
$OfflineQuiescenceCaptureBase64,
"--expected-offline-source-commit",
$ExpectedOfflineSourceCommit,
"--expected-offline-source-tree",
$ExpectedOfflineSourceTree
)
}
Push-Location (Join-Path $resolvedStableSourceRoot "apps\api")
try {
$workerOutput = @(& $resolvedPythonPath @workerArgs)
$workerExitCode = $LASTEXITCODE
} finally {
Pop-Location
}
$serializedOutput = (@($workerOutput) -join "").Trim()
$parsedOutput = $null
if (-not [string]::IsNullOrWhiteSpace($serializedOutput)) {
try {
$parsedOutput = $serializedOutput | ConvertFrom-Json
} catch {
$parsedOutput = $null
}
}
if (
$workerExitCode -ne 0 -or
$null -eq $parsedOutput -or
$parsedOutput.status -ne "initialized" -or
[long]$parsedOutput.preserved_total_size_bytes -ne
$ExpectedPreservedTotalSizeBytes
) {
throw "Public avatar upload initialization failed"
}
Write-Output $serializedOutput
} catch {
if ($freezeOwned) {
if ($freezePublished) {
$freezeRemoved = Remove-OwnedWriteFreeze `
-Path $fullFreezePath `
-OwnedToken $freezeToken
} else {
$freezeRemoved = $false
}
if (-not $freezePublished -and [System.IO.File]::Exists($fullFreezePath)) {
# CreateNew 뒤 sentinel payload를 완성하기 전에 실패했다면 이 경로는 아직
# 다른 프로세스에 공개되지 않은 이 호출 소유 파일이다.
try {
[System.IO.File]::Delete($fullFreezePath)
$freezeRemoved = -not [System.IO.File]::Exists($fullFreezePath)
} catch {
$freezeRemoved = $false
}
} elseif (-not $freezePublished) {
$freezeRemoved = $true
}
if (-not $freezeRemoved) {
throw "Upload initialization failed and owned write freeze could not be removed"
}
if (-not $offlineQuiescenceMode) {
Assert-OnlineUploadWritesRecovered `
-Uri $HealthUrl `
-TimeoutSeconds $FreezeTimeoutSeconds
}
}
throw
}