아바타 저장소 승격 계약을 완성
This commit is contained in:
parent
ac9b702688
commit
ccdcfcd2f5
36 changed files with 14734 additions and 222 deletions
395
scripts/initialize-public-runtime-upload-root.ps1
Normal file
395
scripts/initialize-public-runtime-upload-root.ps1
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
[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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue