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