<# .SYNOPSIS Deploys hcl-catalog-generate.php to harpgamer.com over WebDAV. .DESCRIPTION Uploads server/hcl-catalog-generate.php to the cPanel Web Disk folder named in .env, in an order that never leaves the live file half-written: 1. GET the live file and check it really is the generator. A wrong HCL_REMOTE_DIR is caught here, before anything is written. 2. COPY live -> hcl-catalog-generate.php.bak (one-step rollback) 3. PUT local -> hcl-catalog-generate.php.uploading 4. GET .uploading and compare its SHA-256 with the local file 5. MOVE .uploading -> hcl-catalog-generate.php (atomic on the server) 6. GET the live file and compare its SHA-256 again That read-back only proves the bytes arrived. The generator runs from cron every 4 hours, so the real proof is the public catalog it produces: run again later with -VerifyCatalog. Credentials come from .env in the repository root (svn-ignored; see .env.example) and are never printed. .PARAMETER DryRun Check configuration and the live file, show what would happen, write nothing. .PARAMETER Rollback Restore the live file from the .bak left by the last deploy. .PARAMETER VerifyCatalog Fetch the public catalog (no credentials) and report whether it shows the generator's fixes: real filenames and file-level artifact types. Exit code 0 = fixes visible, 2 = not yet (cron hasn't run), 1 = error. .PARAMETER AllowNew Allow deploying when no generator exists at the remote path yet. Without it, a missing file is treated as a misconfigured HCL_REMOTE_DIR. .PARAMETER AllowUncommitted Allow deploying a local file with uncommitted SVN changes. .PARAMETER EnvFile Settings file. Defaults to .env in the repository root. .PARAMETER SourceFile File to deploy. Defaults to hcl-catalog-generate.php beside this script; tests point it at a copy. .EXAMPLE .\server\deploy-generator.ps1 -DryRun .\server\deploy-generator.ps1 .\server\deploy-generator.ps1 -VerifyCatalog #> [CmdletBinding(DefaultParameterSetName = 'Deploy')] param( [Parameter(ParameterSetName = 'Deploy')] [switch] $DryRun, [Parameter(ParameterSetName = 'Deploy')] [switch] $AllowNew, [Parameter(ParameterSetName = 'Deploy')] [switch] $AllowUncommitted, [Parameter(ParameterSetName = 'Rollback')] [switch] $Rollback, [Parameter(ParameterSetName = 'Verify')] [switch] $VerifyCatalog, [string] $EnvFile = '', [string] $SourceFile = '' ) Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' # Defaults are resolved here rather than in param(): Windows PowerShell 5.1 # leaves $PSScriptRoot empty while evaluating parameter defaults. if (-not $EnvFile) { $EnvFile = Join-Path (Split-Path $PSScriptRoot -Parent) '.env' } if (-not $SourceFile) { $SourceFile = Join-Path $PSScriptRoot 'hcl-catalog-generate.php' } Add-Type -AssemblyName System.Net.Http [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $RemoteName = 'hcl-catalog-generate.php' # The generator's own header comment. Its presence is how a remote file is # recognised as the generator rather than something else at a mistyped path. $GeneratorMarker = ' * hcl-catalog-generate.php' # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- <# .SYNOPSIS Reads KEY=VALUE lines from a .env file. .OUTPUTS Hashtable of settings. Values are never written to the console. #> function Read-DotEnv([string] $Path) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Settings file not found: $Path (copy .env.example to .env and fill it in)" } $settings = @{} foreach ($line in [IO.File]::ReadAllLines($Path)) { $t = $line.Trim() if ($t -eq '' -or $t.StartsWith('#')) { continue } $eq = $t.IndexOf('=') if ($eq -lt 1) { continue } $key = $t.Substring(0, $eq).Trim() $value = $t.Substring($eq + 1).Trim() if ($value.Length -ge 2 -and (($value[0] -eq '"' -and $value[-1] -eq '"') -or ($value[0] -eq "'" -and $value[-1] -eq "'"))) { $value = $value.Substring(1, $value.Length - 2) } $settings[$key] = $value } return $settings } <# .SYNOPSIS Returns the named setting, or throws naming only the key. #> function Get-Setting([hashtable] $Settings, [string] $Key) { if (-not $Settings.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace($Settings[$Key])) { throw "$Key is not set in $EnvFile" } return $Settings[$Key] } <# .SYNOPSIS Lowercase hex SHA-256 of a byte array. #> function Get-Sha256([byte[]] $Bytes) { $sha = [Security.Cryptography.SHA256]::Create() try { return (-join ($sha.ComputeHash($Bytes) | ForEach-Object { $_.ToString('x2') })) } finally { $sha.Dispose() } } <# .SYNOPSIS Builds the full WebDAV URL of a file in the remote folder. #> function Get-RemoteUrl([string] $BaseUrl, [string] $RemoteDir, [string] $Name) { $segments = @($RemoteDir.Trim('/').Split('/') | Where-Object { $_ -ne '' }) + $Name return $BaseUrl.TrimEnd('/') + '/' + (($segments | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/') } <# .SYNOPSIS Sends one WebDAV request. .OUTPUTS PSCustomObject with Status (int) and Body (byte[]). .NOTES Never includes credentials in anything it returns or throws. #> function Invoke-Dav([System.Net.Http.HttpClient] $Client, [string] $Method, [string] $Url, [byte[]] $Body = $null, [hashtable] $Headers = @{}) { $msg = New-Object System.Net.Http.HttpRequestMessage((New-Object System.Net.Http.HttpMethod($Method)), $Url) foreach ($k in $Headers.Keys) { [void]$msg.Headers.TryAddWithoutValidation($k, $Headers[$k]) } if ($null -ne $Body) { $msg.Content = New-Object System.Net.Http.ByteArrayContent(,$Body) } try { $resp = $Client.SendAsync($msg).GetAwaiter().GetResult() } catch { $inner = $_.Exception while ($inner.InnerException) { $inner = $inner.InnerException } throw "$Method $Url failed: $($inner.Message)" } try { return [pscustomobject]@{ Status = [int]$resp.StatusCode Body = $resp.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() } } finally { $resp.Dispose() $msg.Dispose() } } <# .SYNOPSIS Throws unless the response status is one of the expected codes. #> function Assert-Status($Response, [int[]] $Expected, [string] $What) { if ($Expected -notcontains $Response.Status) { $hint = '' if ($Response.Status -eq 401) { $hint = ' (authentication failed - check WEBDAV_USER / WEBDAV_PASS)' } if ($Response.Status -eq 403) { $hint = ' (forbidden - is the Web Disk account scoped to this folder?)' } throw "$What returned HTTP $($Response.Status)$hint" } } <# .SYNOPSIS Creates an HttpClient that sends Basic credentials on every request. #> function New-DavClient([string] $User, [string] $Pass) { $handler = New-Object System.Net.Http.HttpClientHandler $handler.AllowAutoRedirect = $false # never forward credentials elsewhere $client = New-Object System.Net.Http.HttpClient($handler) $client.Timeout = [TimeSpan]::FromSeconds(120) $token = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${User}:${Pass}")) $client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Basic', $token) return $client } # --------------------------------------------------------------------------- # -VerifyCatalog: check the result from outside the transport # --------------------------------------------------------------------------- <# .SYNOPSIS Summarises the public catalog. Needs no credentials. #> function Test-PublicCatalog([string] $CatalogUrl) { $client = New-Object System.Net.Http.HttpClient $client.Timeout = [TimeSpan]::FromSeconds(120) $client.DefaultRequestHeaders.UserAgent.ParseAdd('HCLauncher/1.0') try { $json = $client.GetStringAsync($CatalogUrl).GetAwaiter().GetResult() } finally { $client.Dispose() } $catalog = $json | ConvertFrom-Json $stats = @{ Files = 0; Named = 0; Typed = 0 } $walk = { param($cats) foreach ($c in $cats) { foreach ($f in $c.files) { $stats.Files++ if ($f.fileName) { $stats.Named++ } if ($f.PSObject.Properties['artifactType']) { $stats.Typed++ } } if ($c.subcategories) { & $walk $c.subcategories } } } & $walk $catalog.categories Write-Host ("Catalog generated {0}: {1} files, {2} with a filename, {3} with a file-level artifactType" -f ` $catalog.generated, $stats.Files, $stats.Named, $stats.Typed) if ($stats.Named -gt 0 -and $stats.Typed -gt 0) { Write-Host 'The deployed generator is live: filenames and per-file types are present.' return 0 } Write-Host 'Not yet: the catalog still looks like the old generator''s output. It regenerates every 4 hours.' return 2 } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- try { $settings = Read-DotEnv $EnvFile if ($VerifyCatalog) { exit (Test-PublicCatalog (Get-Setting $settings 'HCL_CATALOG_URL')) } $baseUrl = Get-Setting $settings 'WEBDAV_URL' $user = Get-Setting $settings 'WEBDAV_USER' $pass = Get-Setting $settings 'WEBDAV_PASS' $remoteDir = if ($settings.ContainsKey('HCL_REMOTE_DIR')) { $settings['HCL_REMOTE_DIR'] } else { '/' } # Basic credentials are only as private as the connection they travel on. $baseUri = [Uri]$baseUrl if ($baseUri.Scheme -ne 'https' -and -not $baseUri.IsLoopback) { throw "WEBDAV_URL must use https (got $($baseUri.Scheme)://$($baseUri.Host))" } $liveUrl = Get-RemoteUrl $baseUrl $remoteDir $RemoteName $uploadUrl = Get-RemoteUrl $baseUrl $remoteDir "$RemoteName.uploading" $backupUrl = Get-RemoteUrl $baseUrl $remoteDir "$RemoteName.bak" $client = New-DavClient $user $pass try { if ($Rollback) { $bak = Invoke-Dav $client 'GET' $backupUrl if ($bak.Status -eq 404) { throw "No backup found at $backupUrl" } Assert-Status $bak @(200) 'Reading the backup' Assert-Status (Invoke-Dav $client 'COPY' $backupUrl -Headers @{ Destination = $liveUrl; Overwrite = 'T' }) @(200, 201, 204) 'Restoring the backup' $live = Invoke-Dav $client 'GET' $liveUrl Assert-Status $live @(200) 'Reading back the restored file' if ((Get-Sha256 $live.Body) -ne (Get-Sha256 $bak.Body)) { throw 'Restored file does not match the backup' } Write-Host "Rolled back: $liveUrl now matches the .bak ($(Get-Sha256 $bak.Body))" exit 0 } # --- Local file ------------------------------------------------------- if (-not (Test-Path -LiteralPath $SourceFile -PathType Leaf)) { throw "Source file not found: $SourceFile" } $local = [IO.File]::ReadAllBytes($SourceFile) $localHash = Get-Sha256 $local # Deploy what is committed, so the server always matches a revision. if (-not $AllowUncommitted -and (Get-Command svn -ErrorAction SilentlyContinue)) { # Under 'Stop', Windows PowerShell turns any stderr line from a # native command into a terminating error - including svn's # harmless "not a working copy" warning - so relax it for this call # and keep only stdout lines. $prevPref = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $svnOut = @(& svn status -- $SourceFile 2>&1) } finally { $ErrorActionPreference = $prevPref } $svnState = $svnOut | Where-Object { $_ -is [string] -and $_.Length -gt 0 } | Select-Object -First 1 if ($svnState -and $svnState.Substring(0, 1) -match '[MA?!C~R]') { throw "$SourceFile has uncommitted changes ($($svnState.Substring(0, 1))). Commit first, or pass -AllowUncommitted." } } # --- Live file: exists, and is the generator -------------------------- $live = Invoke-Dav $client 'GET' $liveUrl $liveHash = $null if ($live.Status -eq 404) { if (-not $AllowNew) { throw "No $RemoteName at $liveUrl. If HCL_REMOTE_DIR is right and this is a first deploy, pass -AllowNew." } } else { Assert-Status $live @(200) 'Reading the live file' $liveText = [Text.Encoding]::UTF8.GetString($live.Body) if (-not $liveText.Contains($GeneratorMarker)) { throw "The file at $liveUrl does not look like the catalog generator; refusing to overwrite it. Check HCL_REMOTE_DIR." } $liveHash = Get-Sha256 $live.Body } Write-Host "Remote : $liveUrl" Write-Host ("Local : {0} bytes, sha256 {1}" -f $local.Length, $localHash) if ($liveHash) { Write-Host ("Live : {0} bytes, sha256 {1}" -f $live.Body.Length, $liveHash) } else { Write-Host 'Live : (none)' } if ($liveHash -eq $localHash) { Write-Host 'Already deployed: the live file matches the local one. Nothing to do.' exit 0 } if ($DryRun) { Write-Host 'Dry run - would: back up live -> .bak, upload -> .uploading, verify, move into place, verify.' exit 0 } # --- Deploy ----------------------------------------------------------- # RFC 4918 gives 201/204 for a successful COPY or MOVE, but cPanel's # Web Disk (cpdavd) answers 200, so 200 is accepted too. Success is # judged by the hash read-backs, not by these status codes. if ($liveHash) { Assert-Status (Invoke-Dav $client 'COPY' $liveUrl -Headers @{ Destination = $backupUrl; Overwrite = 'T' }) @(200, 201, 204) 'Backing up the live file' Write-Host "Backed up live file to $RemoteName.bak" } $moved = $false try { Assert-Status (Invoke-Dav $client 'PUT' $uploadUrl -Body $local) @(200, 201, 204) 'Uploading' $check = Invoke-Dav $client 'GET' $uploadUrl Assert-Status $check @(200) 'Reading back the upload' if ((Get-Sha256 $check.Body) -ne $localHash) { throw 'Uploaded copy does not match the local file; live file left unchanged.' } Assert-Status (Invoke-Dav $client 'MOVE' $uploadUrl -Headers @{ Destination = $liveUrl; Overwrite = 'T' }) @(200, 201, 204) 'Moving the upload into place' $moved = $true } finally { if (-not $moved) { # Best effort: don't leave a stray .uploading behind. try { [void](Invoke-Dav $client 'DELETE' $uploadUrl) } catch { } } } $final = Invoke-Dav $client 'GET' $liveUrl Assert-Status $final @(200) 'Reading back the deployed file' if ((Get-Sha256 $final.Body) -ne $localHash) { throw 'Live file does not match after the move. Run with -Rollback to restore the backup.' } Write-Host "Deployed and verified: $liveUrl (sha256 $localHash)" Write-Host 'The generator runs from cron every 4 hours. Afterwards, confirm from outside with -VerifyCatalog.' if ($liveHash) { Write-Host 'To undo: -Rollback restores the previous version from the .bak.' } exit 0 } finally { $client.Dispose() } } catch { Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red exit 1 }