Files
AIGame/Tools/Publish-CdnRelease.ps1

170 lines
11 KiB
PowerShell

param(
[Parameter(Mandatory=$true)][ValidateSet('Check','Plan','Prepare','Publish','Rollback')][string]$Action,
[Parameter(Mandatory=$true)][string]$RequestBase64,
[string]$StateRoot = ''
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
$request = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($RequestBase64)) | ConvertFrom-Json
function Hash([string]$Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' }
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Under([string]$Root, [string]$Relative) {
$full = [IO.Path]::GetFullPath((Join-Path $Root $Relative))
$prefix = $Root.TrimEnd('\','/') + [IO.Path]::DirectorySeparatorChar
if (-not $full.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Path escapes release root: $Relative" }
return $full
}
function Resolve-WebRoot([string]$Path) {
if (-not [IO.Path]::IsPathRooted($Path)) { throw 'Website root must be an absolute path' }
$resolved = (Resolve-Path -LiteralPath $Path).Path.TrimEnd('\','/')
if ($resolved.Length -le 3 -or -not (Test-Path -LiteralPath $resolved -PathType Container)) { throw 'Invalid website root' }
if (-not (Test-Path -LiteralPath (Join-Path $resolved 'cdn/version.json')) -and
-not (Get-ChildItem -LiteralPath $resolved -Filter '*.webgl.data.unityweb.bin.txt' -File | Select-Object -First 1)) {
throw 'Target is not an existing WebGL CDN website (no version or startup data)'
}
return $resolved
}
function Validate-Files($Files) {
$seen = @{}
foreach ($item in @($Files)) {
$name = [string]$item.path
if ($name -ne 'cdn/version.json' -and $name -notmatch '^yoo/(Art|DefaultPackage)/[A-Za-z0-9_.-]+$' -and
$name -cnotmatch '^[a-f0-9]{16}\.webgl\.data\.unityweb\.bin\.txt$') { throw "Unsupported release path: $name" }
if ($name.Split('/') -contains '..' -or $seen.ContainsKey($name)) { throw "Unsafe or duplicate path: $name" }
if ($item.sha256 -notmatch '^[a-f0-9]{64}$' -or [long]$item.size -lt 0) { throw "Invalid file metadata: $name" }
$seen[$name] = $item
}
if (-not $seen.ContainsKey('cdn/version.json')) { throw 'Release has no global version.json' }
return $seen
}
function Atomic-Copy([string]$Source, [string]$Target) {
New-Item -ItemType Directory -Path (Split-Path -Parent $Target) -Force | Out-Null
$temporary = "$Target.upload-$([Guid]::NewGuid().ToString('N')).tmp"
try {
[IO.File]::Copy($Source, $temporary, $false)
if (Test-Path -LiteralPath $Target) { [IO.File]::Replace($temporary, $Target, [System.Management.Automation.Language.NullString]::Value) }
else { [IO.File]::Move($temporary, $Target) }
} finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary } }
}
try {
$root = Resolve-WebRoot $request.root
$current = Join-Path $root 'cdn/version.json'
$base = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'AIGameCdnPublish'
if ($StateRoot) { $base = [IO.Path]::GetFullPath($StateRoot) }
if ($Action -eq 'Check') {
@{ connected=$true; user=[Environment]::UserName; root=$root; currentVersionSha256=(Hash $current) } | ConvertTo-Json -Compress
exit 0
}
if ($request.releaseId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid release ID' }
$folder = Join-Path $base $request.releaseId
$archivePath = Join-Path $folder 'release.zip'
if ($Action -eq 'Plan') {
$files = Validate-Files $request.files
$changed = @($request.files | Where-Object {
$target = Under $root $_.path
-not (Test-Path -LiteralPath $target -PathType Leaf) -or
(Get-Item -LiteralPath $target).Length -ne [long]$_.size -or (Hash $target) -ne $_.sha256
} | ForEach-Object { $_.path })
@{ changed=$changed; currentVersionSha256=(Hash $current); root=$root } | ConvertTo-Json -Compress
exit 0
}
New-Item -ItemType Directory -Path $base -Force | Out-Null
if ($Action -eq 'Prepare') {
$null = Validate-Files $request.files
if (Test-Path -LiteralPath $folder) { throw 'Release staging directory already exists' }
New-Item -ItemType Directory -Path $folder | Out-Null
@{ archive=$archivePath } | ConvertTo-Json -Compress
exit 0
}
# The lock is outside the public website and covers both publishing and rollback.
$sha = [Security.Cryptography.SHA256]::Create()
try { $rootId = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($root.ToLowerInvariant()))).Replace('-','') }
finally { $sha.Dispose() }
$lockPath = Join-Path $base "$rootId.lock"
$lock = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
try {
if ($Action -eq 'Rollback') {
$receipt = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $folder 'receipt.json') | ConvertFrom-Json
if ($receipt.root -ne $root -or (Hash $current) -ne $receipt.versionSha256) { throw 'A different version is live; rollback refused' }
$previous = Join-Path $folder 'previous.version.json'
if (-not (Test-Path -LiteralPath $previous)) { throw 'This release has no previous version to restore' }
$version = Get-Content -Raw -Encoding UTF8 -LiteralPath $previous | ConvertFrom-Json
foreach ($package in @($version.packages)) {
if ($package -notin @('Art','DefaultPackage')) { throw 'Unexpected previous package' }
$versionId = if ($package -eq 'Art') { $version.resVersion } else { $version.packVersion }
if ($versionId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid previous manifest version' }
$index = Under $root "yoo/$package/${package}_$versionId.bytes"
if (-not (Test-Path -LiteralPath $index) -or (Get-FileHash -LiteralPath $index -Algorithm MD5).Hash.ToLowerInvariant() -ne $versionId) {
throw "Previous manifest missing or corrupt: $package"
}
}
Atomic-Copy $previous $current
@{ rolledBack=$true; releaseId=$request.releaseId; version=$version } | ConvertTo-Json -Depth 20 -Compress
exit 0
}
$files = Validate-Files $request.files
if ((Hash $current) -ne $request.expectedVersionSha256) { throw 'Live version changed since planning; make a fresh plan' }
if (Test-Path -LiteralPath (Join-Path $folder 'receipt.json')) { throw 'Release was already committed; inspect its receipt' }
$unpacked = Join-Path $folder 'files'
if (Test-Path -LiteralPath $unpacked) { throw 'Release already extracted; start a fresh release' }
New-Item -ItemType Directory -Path $unpacked | Out-Null
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($archivePath)
$seen = @{}
try {
foreach ($entry in $zip.Entries) {
$name = $entry.FullName
if (-not $files.ContainsKey($name) -or $seen.ContainsKey($name) -or $entry.Length -ne [long]$files[$name].size) { throw "Unexpected ZIP entry: $name" }
$destination = Under $unpacked $name
New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null
$inputStream = $entry.Open()
$outputStream = [IO.File]::Open($destination, [IO.FileMode]::CreateNew)
try { $inputStream.CopyTo($outputStream) } finally { $inputStream.Dispose(); $outputStream.Dispose() }
if ((Hash $destination) -ne $files[$name].sha256) { throw "Uploaded SHA256 mismatch: $name" }
$seen[$name] = $true
}
} finally { $zip.Dispose() }
# Validate the entire release, including unchanged server files, before any website writes.
foreach ($item in @($request.files)) {
$source = if ($seen.ContainsKey($item.path)) { Under $unpacked $item.path } else { Under $root $item.path }
if (-not (Test-Path -LiteralPath $source -PathType Leaf) -or (Get-Item -LiteralPath $source).Length -ne [long]$item.size -or (Hash $source) -ne $item.sha256) {
throw "Missing or corrupt release file: $($item.path)"
}
}
$versionSource = if ($seen.ContainsKey('cdn/version.json')) { Under $unpacked 'cdn/version.json' } else { $current }
$version = Get-Content -Raw -Encoding UTF8 -LiteralPath $versionSource | ConvertFrom-Json
if ($version.cdnRoot -ne $request.version.cdnRoot -or $version.packVersion -ne $request.version.packVersion -or
$version.resVersion -ne $request.version.resVersion -or (@($version.packages) -join ',') -ne (@($request.version.packages) -join ',')) {
throw 'Global version payload does not match the request'
}
foreach ($package in @($version.packages)) {
if ($package -notin @('Art','DefaultPackage')) { throw 'Unexpected package name' }
$versionId = if ($package -eq 'Art') { $version.resVersion } else { $version.packVersion }
if ($versionId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid package version' }
$name = "yoo/$package/${package}_$versionId.bytes"
if (-not $files.ContainsKey($name)) { throw "Manifest absent from release: $name" }
$source = if ($seen.ContainsKey($name)) { Under $unpacked $name } else { Under $root $name }
if ((Get-FileHash -LiteralPath $source -Algorithm MD5).Hash.ToLowerInvariant() -ne $versionId) { throw "Manifest MD5 mismatch: $package" }
}
if (Test-Path -LiteralPath $current) { [IO.File]::Copy($current, (Join-Path $folder 'previous.version.json'), $false) }
foreach ($name in @($seen.Keys | Where-Object { $_ -ne 'cdn/version.json' } | Sort-Object)) {
Atomic-Copy (Under $unpacked $name) (Under $root $name)
}
# Commit marker is always last. All older content-addressed resources remain available.
if ($seen.ContainsKey('cdn/version.json')) { Atomic-Copy (Under $unpacked 'cdn/version.json') $current }
$receipt = @{ published=$true; releaseId=$request.releaseId; root=$root; version=$version;
changedFiles=$seen.Count; versionSha256=(Hash $current); publishedUtc=[DateTime]::UtcNow.ToString('o') }
$receiptJson = $receipt | ConvertTo-Json -Depth 20 -Compress
[IO.File]::WriteAllText((Join-Path $folder 'receipt.json'), $receiptJson, (New-Object Text.UTF8Encoding($false)))
Write-Output $receiptJson
} finally { $lock.Dispose() }
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
exit 1
}