param( [string]$ConfigPath = (Join-Path $PSScriptRoot 'config.txt'), [switch]$Help ) # PAM 部署主脚本(PowerShell 实现)。 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Show-DeployUsage { @' Usage: powershell -File .\deploy.ps1 [-ConfigPath .\config.txt] Notes: - deploy.bat is only a wrapper for this script. - The wrapper avoids cmd.exe delayed-expansion issues with CLIENT_SECRET values containing exclamation marks. '@ | Write-Host } function Write-Info([string]$Message) { Write-Host "[INFO] $Message" } function Write-WarnLog([string]$Message) { Write-Host "[WARN] $Message" } function Write-ErrLog([string]$Message) { Write-Host "[ERROR] $Message" } function Convert-ResponseContent { param([AllowNull()][string]$Content) if ([string]::IsNullOrWhiteSpace($Content)) { return $null } try { if ($PSVersionTable.PSVersion.Major -ge 6) { return $Content | ConvertFrom-Json -Depth 100 } return $Content | ConvertFrom-Json } catch { return $Content } } function Get-ErrorBody { param([System.Management.Automation.ErrorRecord]$ErrorRecord) try { $response = $ErrorRecord.Exception.Response if ($null -eq $response) { return $ErrorRecord.Exception.Message } $stream = $response.GetResponseStream() if ($null -eq $stream) { return $ErrorRecord.Exception.Message } $reader = New-Object System.IO.StreamReader($stream) try { return $reader.ReadToEnd() } finally { $reader.Dispose() } } catch { return $ErrorRecord.Exception.Message } } function Get-ResponseValue { param( $Response, [string[]]$Candidates ) foreach ($candidate in $Candidates) { $current = $Response foreach ($segment in ($candidate -split '\.')) { if ($null -eq $current) { break } if ($current -is [string]) { $current = $null break } if ($current -is [System.Collections.IDictionary]) { if ($current.Contains($segment)) { $current = $current[$segment] } else { $current = $null break } } elseif ($current.PSObject.Properties.Name -contains $segment) { $current = $current.$segment } else { $current = $null break } } if ($null -ne $current -and -not [string]::IsNullOrWhiteSpace([string]$current)) { return [string]$current } } return $null } function Get-PamConfig { param([string]$Path) $config = [ordered]@{} if (Test-Path -LiteralPath $Path) { foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) { $line = $rawLine.TrimEnd("`r") if ($line -match '^\s*$' -or $line -match '^\s*[#;]') { continue } $index = $line.IndexOf('=') if ($index -lt 1) { continue } $key = $line.Substring(0, $index).Trim() $value = $line.Substring($index + 1).Trim() if ($value -match '^(.*?\S)\s+[;#].*$') { $value = $Matches[1] } switch ($key) { 'HOME_BASE_URL' { $config[$key] = $value } 'CLIENT_ID' { $config[$key] = $value } 'CLIENT_SECRET' { $config[$key] = $value } 'AIRPORT_CODE' { $config[$key] = $value } 'APP_NAME' { $config[$key] = $value } 'MODULE_NAME' { $config[$key] = $value } 'VERSION_NUMBER' { $config[$key] = $value } 'ZIP_FILE_PATH' { $config[$key] = $value } 'ACTION_TYPE' { $config[$key] = $value } 'TIMEOUT' { $config[$key] = $value } 'LOG_NAME' { $config[$key] = $value } } } } else { Write-WarnLog "Config file not found: $Path. Defaults will be used." } $defaults = [ordered]@{ HOME_BASE_URL = 'https://pam.home.com' CLIENT_ID = 'your_client_id' CLIENT_SECRET = 'your_client_secret' AIRPORT_CODE = 'HET' APP_NAME = 'PAM' MODULE_NAME = 'Node' VERSION_NUMBER = '2.0.5' ZIP_FILE_PATH = 'C:\path\to\pam-2.0.5.zip' ACTION_TYPE = 'FULL' TIMEOUT = '120' LOG_NAME = 'app.log' } foreach ($name in $defaults.Keys) { if (-not $config.Contains($name) -or [string]::IsNullOrWhiteSpace([string]$config[$name])) { $config[$name] = $defaults[$name] } } return [pscustomobject]$config } function Join-RequestPairs { param([System.Collections.IDictionary]$Values) $pairs = foreach ($key in $Values.Keys) { $encodedValue = [System.Uri]::EscapeDataString([string]$Values[$key]) '{0}={1}' -f $key, $encodedValue } return ($pairs -join '&') } function Test-ZipFile { param($Config) if (-not (Test-Path -LiteralPath $Config.ZIP_FILE_PATH)) { throw "Package file not found: $($Config.ZIP_FILE_PATH)" } } function Invoke-PamWebRequest { param( [ValidateSet('GET', 'POST', 'PUT')] [string]$Method, [string]$Url, [string]$Token, [hashtable]$Headers = @{}, [AllowNull()]$Body = $null, [string]$ContentType = '', [string]$OutFile = '' ) $allHeaders = @{} if ($Token) { $allHeaders['Authorization'] = "Bearer $Token" } foreach ($key in $Headers.Keys) { $allHeaders[$key] = $Headers[$key] } $params = @{ Uri = $Url Method = $Method Headers = $allHeaders ErrorAction = 'Stop' } if ($PSVersionTable.PSVersion.Major -lt 6) { $params['UseBasicParsing'] = $true } if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body -and "$Body" -ne '') { $params['Body'] = $Body } if ($ContentType) { $params['ContentType'] = $ContentType } if ($OutFile) { $params['OutFile'] = $OutFile } try { $response = Invoke-WebRequest @params if ($OutFile) { return $OutFile } return Convert-ResponseContent $response.Content } catch { $body = Get-ErrorBody $_ throw "Request failed [$Method] $Url`n$body" } } function Invoke-PamMultipartUpload { param( [string]$Url, [string]$Token, [string]$FilePath, [hashtable]$Fields ) Add-Type -AssemblyName System.Net.Http $client = [System.Net.Http.HttpClient]::new() try { $client.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token) $content = [System.Net.Http.MultipartFormDataContent]::new() foreach ($entry in $Fields.GetEnumerator()) { $stringContent = [System.Net.Http.StringContent]::new([string]$entry.Value, [System.Text.Encoding]::UTF8) $content.Add($stringContent, $entry.Key) } $stream = [System.IO.File]::OpenRead($FilePath) try { $fileContent = [System.Net.Http.StreamContent]::new($stream) $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/octet-stream') $content.Add($fileContent, 'file', [System.IO.Path]::GetFileName($FilePath)) $response = $client.PostAsync($Url, $content).GetAwaiter().GetResult() $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() if (-not $response.IsSuccessStatusCode) { throw "Upload failed [HTTP $([int]$response.StatusCode)]`n$body" } return Convert-ResponseContent $body } finally { $stream.Dispose() } } finally { $client.Dispose() } } function Get-Token { param($Config) Write-Info 'Getting token...' $body = Join-RequestPairs ([ordered]@{ grant_type = 'client_credentials' client_id = $Config.CLIENT_ID client_secret = $Config.CLIENT_SECRET }) $response = Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/oauth/token" -Token '' -Body $body -ContentType 'application/x-www-form-urlencoded' $token = Get-ResponseValue -Response $response -Candidates @('access_token') if (-not $token) { throw "Invalid token response: $response" } return $token } function New-VersionRecord { param($Config, [string]$Token) Write-Info 'Step 2.1: create version record' $body = Join-RequestPairs ([ordered]@{ versionNumber = $Config.VERSION_NUMBER applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME description = 'Auto Deploy' }) [void](Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/api/version/upgrade" -Token $Token -Body $body -ContentType 'application/x-www-form-urlencoded') } function Upload-Package { param($Config, [string]$Token) Write-Info 'Step 2.2: upload package' $response = Invoke-PamMultipartUpload -Url "$($Config.HOME_BASE_URL)/api/version/upgrade/upload" -Token $Token -FilePath $Config.ZIP_FILE_PATH -Fields @{ applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME versionNumber = $Config.VERSION_NUMBER } $hashCode = Get-ResponseValue -Response $response -Candidates @('hashCode', 'data.hashCode') if (-not $hashCode -and $response -is [string]) { $hashCode = $response.Trim() } if (-not $hashCode) { throw "Unable to parse hashCode from upload response: $response" } return $hashCode } function Publish-Version { param($Config, [string]$Token, [string]$HashCode) Write-Info 'Step 2.3: publish version' $payload = @{ airportCodesWhite = @($Config.AIRPORT_CODE) hashCode = $HashCode state = 'RELEASE' } | ConvertTo-Json -Depth 5 $query = Join-RequestPairs ([ordered]@{ versionNumber = $Config.VERSION_NUMBER applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME }) [void](Invoke-PamWebRequest -Method PUT -Url "$($Config.HOME_BASE_URL)/api/version/upgrade/profile?$query" -Token $Token -Body $payload -ContentType 'application/json') } function Get-NodeUrl { param($Config, [string]$Token) Write-Info 'Step 3.1: resolve node url' $response = Invoke-PamWebRequest -Method GET -Url "$($Config.HOME_BASE_URL)/api/mcp/airport/target-node?airportCode=$($Config.AIRPORT_CODE)" -Token $Token if ($response -is [System.Collections.IDictionary]) { return [string]($response.Keys | Select-Object -First 1) } $propertyNames = @($response.PSObject.Properties.Name) if ($propertyNames.Count -gt 0) { return [string]$propertyNames[0] } throw "Unable to resolve node url: $response" } function Get-OnlineIps { param($Config, [string]$Token, [string]$NodeUrl) Write-Info 'Step 3.2: query online IP list' $query = Join-RequestPairs ([ordered]@{ applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME airportCode = $Config.AIRPORT_CODE }) $response = Invoke-PamWebRequest -Method GET -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/ips?$query" -Token $Token -Headers @{ 'Target-Node' = $NodeUrl } $ips = @() if ($response -is [System.Array]) { $ips = @($response | ForEach-Object { [string]$_ } | Where-Object { $_ }) } elseif ($response -is [System.Collections.IEnumerable] -and -not ($response -is [string])) { $ips = @($response | ForEach-Object { [string]$_ } | Where-Object { $_ }) } if ($ips.Count -eq 0) { throw "No online workstation matched the module. Raw response: $response" } return $ips } function Wait-DownloadProgress { param($Config, [string]$Token, [string]$NodeUrl) $query = Join-RequestPairs ([ordered]@{ applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME airportCode = $Config.AIRPORT_CODE }) $progressUrl = "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/download-cloud/progress?$query" for ($attempt = 0; $attempt -lt 60; $attempt++) { $response = Invoke-PamWebRequest -Method GET -Url $progressUrl -Token $Token -Headers @{ 'Target-Node' = $NodeUrl } $status = Get-ResponseValue -Response $response -Candidates @('status') $successFlag = Get-ResponseValue -Response $response -Candidates @('success') if ($status -eq 'completed' -or $successFlag -eq 'true') { return } $message = Get-ResponseValue -Response $response -Candidates @('message') if ($message -and $message -match '(?i)fail|error') { throw "Node download failed: $message" } Start-Sleep -Seconds 2 } throw 'Node download timed out.' } function Download-CloudToNode { param($Config, [string]$Token, [string]$NodeUrl) Write-Info 'Step 3.3: download package to node' $query = Join-RequestPairs ([ordered]@{ versionNumber = $Config.VERSION_NUMBER applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME timeOut = $Config.TIMEOUT }) [void](Invoke-PamWebRequest -Method GET -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/download-cloud?$query" -Token $Token -Headers @{ 'Target-Node' = $NodeUrl 'airport-code' = $Config.AIRPORT_CODE }) Wait-DownloadProgress -Config $Config -Token $Token -NodeUrl $NodeUrl } function Invoke-UpgradeRequest { param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip) $body = Join-RequestPairs ([ordered]@{ airportCode = $Config.AIRPORT_CODE targetIp = $Ip applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME versionNumber = $Config.VERSION_NUMBER action = $Config.ACTION_TYPE autoStart = 'false' timeOut = $Config.TIMEOUT }) Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade" -Token $Token -Headers @{ 'Target-Node' = $NodeUrl } -Body $body -ContentType 'application/x-www-form-urlencoded' } function Start-Application { param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip) $body = Join-RequestPairs ([ordered]@{ airportCode = $Config.AIRPORT_CODE targetIp = $Ip applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME runstart = 'true' }) [void](Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/start-stop" -Token $Token -Headers @{ 'Target-Node' = $NodeUrl } -Body $body -ContentType 'application/x-www-form-urlencoded') } function Stop-Application { param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip) $body = Join-RequestPairs ([ordered]@{ airportCode = $Config.AIRPORT_CODE targetIp = $Ip applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME runstart = 'false' }) [void](Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/start-stop" -Token $Token -Headers @{ 'Target-Node' = $NodeUrl } -Body $body -ContentType 'application/x-www-form-urlencoded') } function Verify-Ip { param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip) $query = Join-RequestPairs ([ordered]@{ applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME airportCode = $Config.AIRPORT_CODE targetIp = $Ip }) Invoke-PamWebRequest -Method GET -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/verify?$query" -Token $Token -Headers @{ 'Target-Node' = $NodeUrl } } function Download-DeployLog { param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip) $logsDir = Join-Path $PSScriptRoot 'logs' if (-not (Test-Path -LiteralPath $logsDir)) { $null = New-Item -ItemType Directory -Path $logsDir } $logFile = Join-Path $logsDir ("deploy_{0}.log" -f $Ip) $errorFile = Join-Path $logsDir ("error_{0}.log" -f $Ip) $query = Join-RequestPairs ([ordered]@{ applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME airportCode = $Config.AIRPORT_CODE targetIp = $Ip logName = $Config.LOG_NAME }) try { [void](Invoke-PamWebRequest -Method GET -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/log-download?$query" -Token $Token -Headers @{ 'Target-Node' = $NodeUrl } -OutFile $logFile) if ((Get-Item -LiteralPath $logFile).Length -gt 0) { Get-Content -LiteralPath $logFile -Tail 5 | Set-Content -LiteralPath "$logFile.summary" } else { 'Log content empty or no data' | Set-Content -LiteralPath "$logFile.summary" } } catch { Get-ErrorBody $_ | Set-Content -LiteralPath $errorFile "Log download failed. See $errorFile" | Set-Content -LiteralPath $logFile 'Log download failed' | Set-Content -LiteralPath "$logFile.summary" } return $logFile } function Invoke-Rollback { param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip, [bool]$StopFirst) if ($StopFirst) { try { Stop-Application -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip } catch { } } try { $body = Join-RequestPairs ([ordered]@{ airportCode = $Config.AIRPORT_CODE targetIp = $Ip applicationName = $Config.APP_NAME moduleName = $Config.MODULE_NAME timeOut = $Config.TIMEOUT }) $response = Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/rollback" -Token $Token -Headers @{ 'Target-Node' = $NodeUrl } -Body $body -ContentType 'application/x-www-form-urlencoded' $rollbackSuccess = Get-ResponseValue -Response $response -Candidates @('success') if ($rollbackSuccess -and $rollbackSuccess -ne 'true') { return 'ROLLBACK_FAILED' } } catch { return 'ROLLBACK_REQUEST_FAILED' } try { $verify = Verify-Ip -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip if ((Get-ResponseValue -Response $verify -Candidates @('success')) -eq 'true') { return 'ROLLBACK_SUCCESS' } return 'ROLLBACK_VERIFY_FAILED' } catch { return 'ROLLBACK_VERIFY_FAILED' } } function Invoke-IpDeploy { param( $Config, [string]$Token, [string]$NodeUrl, [string]$Ip ) Write-Info "Processing IP: $Ip" try { $upgrade = Invoke-UpgradeRequest -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip } catch { $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip return [pscustomobject]@{ Ip = $Ip Status = 'FAILED' Stage = 'UPGRADE' Message = 'Upgrade request failed' Rollback = 'ROLLBACK_NOT_RUN' LogFile = $logFile } } if ((Get-ResponseValue -Response $upgrade -Candidates @('success')) -ne 'true') { $rollback = Invoke-Rollback -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip -StopFirst:$false $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip $message = Get-ResponseValue -Response $upgrade -Candidates @('message') if (-not $message) { $message = 'Upgrade failed' } return [pscustomobject]@{ Ip = $Ip Status = 'FAILED' Stage = 'UPGRADE' Message = $message Rollback = $rollback LogFile = $logFile } } try { Start-Application -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip } catch { $rollback = Invoke-Rollback -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip -StopFirst:$true $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip return [pscustomobject]@{ Ip = $Ip Status = 'FAILED' Stage = 'START' Message = 'Application start failed' Rollback = $rollback LogFile = $logFile } } try { $verify = Verify-Ip -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip } catch { $rollback = Invoke-Rollback -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip -StopFirst:$true $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip return [pscustomobject]@{ Ip = $Ip Status = 'FAILED' Stage = 'VERIFY' Message = 'Health check request failed' Rollback = $rollback LogFile = $logFile } } if ((Get-ResponseValue -Response $verify -Candidates @('success')) -eq 'true') { $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip return [pscustomobject]@{ Ip = $Ip Status = 'SUCCESS' Stage = '-' Message = '-' Rollback = '-' LogFile = $logFile } } $rollback = Invoke-Rollback -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip -StopFirst:$true $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip $message = Get-ResponseValue -Response $verify -Candidates @('message') if (-not $message) { $message = 'Health check failed' } return [pscustomobject]@{ Ip = $Ip Status = 'FAILED' Stage = 'VERIFY' Message = $message Rollback = $rollback LogFile = $logFile } } function Write-DeployReport { param( $Config, [System.Collections.Generic.List[object]]$Results, [int]$TotalCount ) $successCount = @($Results | Where-Object { $_.Status -eq 'SUCCESS' }).Count $failCount = @($Results | Where-Object { $_.Status -ne 'SUCCESS' }).Count Write-Host '' Write-Host '====================== DEPLOY REPORT ======================' Write-Host 'Mode: Batch/PowerShell' Write-Host "Airport: $($Config.AIRPORT_CODE)" Write-Host "Application: $($Config.APP_NAME)" Write-Host "Module: $($Config.MODULE_NAME)" Write-Host "Version: $($Config.VERSION_NUMBER)" Write-Host "Total: $TotalCount" Write-Host "Success: $successCount" Write-Host "Failed: $failCount" Write-Host '' Write-Host ('{0,-18} {1,-8} {2,-12} {3,-22} {4}' -f 'IP', 'STATUS', 'STAGE', 'ROLLBACK', 'LOG') foreach ($item in $Results) { Write-Host ('{0,-18} {1,-8} {2,-12} {3,-22} {4}' -f $item.Ip, $item.Status, $item.Stage, $item.Rollback, $item.LogFile) if ($item.Status -ne 'SUCCESS') { Write-Host (" Reason: {0}" -f $item.Message) } } } function Invoke-PamDeploy { param([string]$ConfigPath) $config = Get-PamConfig -Path $ConfigPath Test-ZipFile -Config $config Write-Info "Deploy start: airport=$($config.AIRPORT_CODE), version=$($config.VERSION_NUMBER), module=$($config.APP_NAME)/$($config.MODULE_NAME)" $token = Get-Token -Config $config New-VersionRecord -Config $config -Token $token $hashCode = Upload-Package -Config $config -Token $token Publish-Version -Config $config -Token $token -HashCode $hashCode $nodeUrl = Get-NodeUrl -Config $config -Token $token $ips = Get-OnlineIps -Config $config -Token $token -NodeUrl $nodeUrl Download-CloudToNode -Config $config -Token $token -NodeUrl $nodeUrl $results = [System.Collections.Generic.List[object]]::new() foreach ($ip in $ips) { $results.Add((Invoke-IpDeploy -Config $config -Token $token -NodeUrl $nodeUrl -Ip $ip)) } Write-DeployReport -Config $config -Results $results -TotalCount $ips.Count } if ($Help) { Show-DeployUsage exit 0 } if ($MyInvocation.InvocationName -ne '.') { try { Invoke-PamDeploy -ConfigPath $ConfigPath } catch { Write-ErrLog $_ exit 1 } }