acdream/tools/new-campaign-la-update-fixture.ps1

333 lines
14 KiB
PowerShell

<#
.SYNOPSIS
Creates deterministic isolated Campaign LA A/B update feeds.
.DESCRIPTION
Packages caller-supplied published client and launcher roots for win-x64
and linux-x64, adds a deterministic release marker, calculates the exact
LA10 SHA-256/size manifest fields, and emits a loopback-only static server
plus a local A/B selector. It never downloads, connects, edits a payload
source, or writes outside -OutputDirectory.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$OutputDirectory,
[Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA,
[Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA,
[Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA,
[Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA,
[Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB,
[Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB,
[Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB,
[Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB,
[string]$VersionA = '1.0.1-la11.a',
[string]$VersionB = '1.0.1-la11.b',
[string]$MinimumLauncherVersion = '1.0.0',
[int]$Port = 43119,
[switch]$DryRun
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion.Major -lt 7) {
throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.'
}
if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
throw '-OutputDirectory must be absolute.'
}
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath($OutputDirectory))
if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' }
$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$'
if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or
$MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) {
throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.'
}
$parsedVersionA = [semver]$VersionA
$parsedVersionB = [semver]$VersionB
$parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion
if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) {
throw 'VersionB must be newer than VersionA.'
}
if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) {
throw 'MinimumLauncherVersion must not be newer than VersionA.'
}
$sources = [ordered]@{
'A-client-win-x64' = $ClientWinX64DirectoryA
'A-launcher-win-x64' = $LauncherWinX64DirectoryA
'A-client-linux-x64' = $ClientLinuxX64DirectoryA
'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA
'B-client-win-x64' = $ClientWinX64DirectoryB
'B-launcher-win-x64' = $LauncherWinX64DirectoryB
'B-client-linux-x64' = $ClientLinuxX64DirectoryB
'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB
}
foreach ($key in @($sources.Keys)) {
$source = [string]$sources[$key]
if (-not [IO.Path]::IsPathFullyQualified($source)) {
throw "Payload source '$key' must be absolute."
}
$source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source))
$sources[$key] = $source
if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) {
throw "Payload source '$key' does not exist: $source"
}
}
function Require-PayloadFile([string]$Key, [string]$Name) {
if ($DryRun) { return }
if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) {
throw "Payload source '$Key' is missing root file '$Name'."
}
}
foreach ($release in @('A', 'B')) {
Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe'
Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe'
Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe'
Require-PayloadFile "$release-client-linux-x64" 'AcDream.App'
Require-PayloadFile "$release-client-linux-x64" 'acdream-headless'
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher'
}
if (Test-Path -LiteralPath $OutputDirectory) {
if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) {
throw '-OutputDirectory must not already contain files.'
}
}
else { $null = New-Item -ItemType Directory -Path $OutputDirectory }
if ($DryRun) {
$plan = [ordered]@{
schemaVersion = 1
kind = 'campaign-la-update-fixture-plan'
outputDirectory = $OutputDirectory
versions = @($VersionA, $VersionB)
minimumLauncherVersion = $MinimumLauncherVersion
port = $Port
sources = $sources
writesOutsideOutputDirectory = $false
externalNetwork = $false
}
$plan | ConvertTo-Json -Depth 5 |
Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM
Write-Host "Campaign LA update fixture dry run: $OutputDirectory"
return
}
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
function New-DeterministicZip(
[string]$SourceDirectory,
[string]$Destination,
[string]$ReleaseLabel,
[string]$PayloadKind,
[string]$Rid) {
$destinationDirectory = Split-Path -Parent $Destination
$null = New-Item -ItemType Directory -Force -Path $destinationDirectory
$stream = [IO.FileStream]::new(
$Destination,
[IO.FileMode]::CreateNew,
[IO.FileAccess]::ReadWrite,
[IO.FileShare]::None)
try {
$archive = [IO.Compression.ZipArchive]::new(
$stream,
[IO.Compression.ZipArchiveMode]::Create,
$true,
[Text.Encoding]::UTF8)
try {
$files = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse |
Sort-Object { [IO.Path]::GetRelativePath($SourceDirectory, $_.FullName).Replace('\', '/') })
foreach ($file in $files) {
if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Payload contains a reparse point: $($file.FullName)"
}
$relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/')
if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or
[IO.Path]::IsPathRooted($relative)) {
throw "Payload path escaped its root: $relative"
}
$entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal)
$entry.LastWriteTime = $fixedTimestamp
$executable = $relative -ceq 'AcDream.App' -or
$relative -ceq 'acdream-headless' -or
$relative -ceq 'acdream-launcher' -or
$relative.EndsWith('.sh', [StringComparison]::Ordinal)
$mode = if ($executable) { 0x81ED } else { 0x81A4 }
$entry.ExternalAttributes = $mode -shl 16
$input = [IO.File]::OpenRead($file.FullName)
$output = $entry.Open()
try { $input.CopyTo($output) }
finally { $output.Dispose(); $input.Dispose() }
}
$marker = $archive.CreateEntry(
'campaign-la-fixture-release.txt',
[IO.Compression.CompressionLevel]::Optimal)
$marker.LastWriteTime = $fixedTimestamp
$marker.ExternalAttributes = 0x81A4 -shl 16
$writer = [IO.StreamWriter]::new(
$marker.Open(),
[Text.UTF8Encoding]::new($false))
try {
$writer.NewLine = "`n"
$writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n")
}
finally { $writer.Dispose() }
}
finally { $archive.Dispose() }
}
finally { $stream.Dispose() }
}
function Get-Artifact([string]$Path, [string]$Url) {
$item = Get-Item -LiteralPath $Path
return [ordered]@{
url = $Url
sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
size = $item.Length
}
}
$releaseDefinitions = @(
[pscustomobject]@{ Label = 'A'; Version = $VersionA },
[pscustomobject]@{ Label = 'B'; Version = $VersionB }
)
foreach ($release in $releaseDefinitions) {
$releaseRoot = Join-Path $OutputDirectory $release.Label
foreach ($rid in @('win-x64', 'linux-x64')) {
New-DeterministicZip `
$sources["$($release.Label)-client-$rid"] `
(Join-Path $releaseRoot "client-$rid.zip") `
$release.Label 'client' $rid
New-DeterministicZip `
$sources["$($release.Label)-launcher-$rid"] `
(Join-Path $releaseRoot "launcher-$rid.zip") `
$release.Label 'launcher' $rid
}
$baseUri = "http://127.0.0.1:$Port/$($release.Label)"
$manifest = [ordered]@{
schemaVersion = 1
version = $release.Version
minimumLauncherVersion = $MinimumLauncherVersion
clients = [ordered]@{
'win-x64' = Get-Artifact `
(Join-Path $releaseRoot 'client-win-x64.zip') `
"$baseUri/client-win-x64.zip"
'linux-x64' = Get-Artifact `
(Join-Path $releaseRoot 'client-linux-x64.zip') `
"$baseUri/client-linux-x64.zip"
}
launchers = [ordered]@{
'win-x64' = Get-Artifact `
(Join-Path $releaseRoot 'launcher-win-x64.zip') `
"$baseUri/launcher-win-x64.zip"
'linux-x64' = Get-Artifact `
(Join-Path $releaseRoot 'launcher-linux-x64.zip') `
"$baseUri/launcher-linux-x64.zip"
}
}
$manifest | ConvertTo-Json -Depth 8 -Compress |
Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM
}
Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') `
-Value 'A' -Encoding ascii -NoNewline
$server = @'
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][int]$Port
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
$prefix = "http://127.0.0.1:$Port/"
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add($prefix)
$listener.Start()
Write-Host "Campaign LA fixture listening on $prefix"
try {
while ($listener.IsListening) {
$context = $listener.GetContext()
try {
if ($context.Request.HttpMethod -cne 'GET') {
$context.Response.StatusCode = 405
continue
}
$relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/'))
if ($relative -ceq 'manifest.json') {
$active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim()
if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' }
$relative = "$active/manifest.json"
}
if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) {
$context.Response.StatusCode = 404
continue
}
$path = [IO.Path]::GetFullPath((Join-Path $Root $relative))
if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or
-not (Test-Path -LiteralPath $path -PathType Leaf)) {
$context.Response.StatusCode = 404
continue
}
$context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) {
'application/json'
} else { 'application/zip' }
$context.Response.StatusCode = 200
$context.Response.Headers['Cache-Control'] = 'no-store'
$context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length
$input = [IO.File]::OpenRead($path)
try { $input.CopyTo($context.Response.OutputStream) }
finally { $input.Dispose() }
}
catch {
$context.Response.StatusCode = 500
Write-Error $_
}
finally { $context.Response.Close() }
}
}
finally { $listener.Close() }
'@
$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM
$selector = @'
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release,
[string]$Root = $PSScriptRoot
)
Set-StrictMode -Version Latest
$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt'
Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline
Write-Host "Campaign LA fixture active release: $Release"
'@
$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM
$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
Where-Object { $_.Name -ne 'fixture-report.json' } |
Sort-Object FullName |
ForEach-Object {
[ordered]@{
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
size = $_.Length
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
}
})
$report = [ordered]@{
schemaVersion = 1
kind = 'campaign-la-update-fixture'
versions = [ordered]@{ A = $VersionA; B = $VersionB }
minimumLauncherVersion = $MinimumLauncherVersion
manifestUri = "http://127.0.0.1:$Port/manifest.json"
loopbackOnly = $true
initialRelease = 'A'
sourceDirectories = $sources
artifacts = $inventory
}
$report | ConvertTo-Json -Depth 8 |
Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM
Write-Host "Campaign LA update fixture: $OutputDirectory"