169 lines
7.4 KiB
PowerShell
169 lines
7.4 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[ValidateSet("deploy", "preflight", "status", "rollback")]
|
|
[string]$Action = "deploy",
|
|
|
|
[string]$ProjectPath,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern("^[A-Za-z0-9][A-Za-z0-9_-]*$")]
|
|
[string]$AppName,
|
|
|
|
[ValidateSet("auto", "compose", "dockerfile")]
|
|
[string]$Mode = "auto",
|
|
|
|
[string]$ServerHost = "192.168.31.51",
|
|
[string]$ServerUser = "jsxq",
|
|
[int]$SshPort = 22,
|
|
[string]$DeployRoot = "/home/jsxq/apps",
|
|
[string]$HealthUrl = "",
|
|
[int]$PublishPort = 0,
|
|
[int]$ContainerPort = 0,
|
|
[switch]$SkipHealthCheck,
|
|
[switch]$KeepArchive
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
$script:ArchivePath = $null
|
|
$script:KnownHostsFile = Join-Path ([IO.Path]::GetTempPath()) "codex-company-linux-known-hosts"
|
|
$AppName = $AppName.ToLowerInvariant()
|
|
|
|
function Require-Command([string]$Name) {
|
|
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
|
throw "Required command not found: $Name"
|
|
}
|
|
}
|
|
|
|
function Quote-Sh([string]$Value) {
|
|
return "'" + $Value.Replace("'", "'`"'`"'") + "'"
|
|
}
|
|
|
|
function Invoke-Ssh([string]$RemoteCommand, [switch]$BatchMode) {
|
|
$arguments = @("-T", "-p", "$SshPort", "-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=$script:KnownHostsFile", "-o", "ConnectTimeout=10")
|
|
if ($BatchMode) {
|
|
$arguments += @("-o", "BatchMode=yes")
|
|
} else {
|
|
$arguments += @("-o", "NumberOfPasswordPrompts=1")
|
|
}
|
|
$arguments += @("$ServerUser@$ServerHost", $RemoteCommand)
|
|
& ssh.exe @arguments
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "SSH command failed with exit code $LASTEXITCODE"
|
|
}
|
|
}
|
|
|
|
function Invoke-Scp([string]$LocalPath, [string]$RemotePath) {
|
|
& scp.exe -P $SshPort -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$script:KnownHostsFile" -o ConnectTimeout=10 -o NumberOfPasswordPrompts=1 $LocalPath "$ServerUser@$ServerHost`:$RemotePath"
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "SCP upload failed with exit code $LASTEXITCODE"
|
|
}
|
|
}
|
|
|
|
function Enable-AskPass {
|
|
if ([string]::IsNullOrWhiteSpace($env:COMPANY_LINUX_PASSWORD)) {
|
|
return $false
|
|
}
|
|
$env:SSH_ASKPASS = Join-Path $PSScriptRoot "ssh-askpass.cmd"
|
|
$env:SSH_ASKPASS_REQUIRE = "force"
|
|
$env:DISPLAY = "codex"
|
|
return $true
|
|
}
|
|
|
|
Require-Command "ssh.exe"
|
|
|
|
$oldAskPass = $env:SSH_ASKPASS
|
|
$oldAskPassRequire = $env:SSH_ASKPASS_REQUIRE
|
|
$oldDisplay = $env:DISPLAY
|
|
$usingPassword = Enable-AskPass
|
|
|
|
try {
|
|
if (-not $usingPassword) {
|
|
& ssh.exe -T -p $SshPort -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$script:KnownHostsFile" -o ConnectTimeout=8 "$ServerUser@$ServerHost" "true"
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "SSH key authentication failed. Run setup-ssh-key.ps1 or set COMPANY_LINUX_PASSWORD only for this process."
|
|
}
|
|
}
|
|
|
|
if ($Action -eq "preflight") {
|
|
$preflight = "set -eu; echo '=== identity ==='; id; echo '=== runtime ==='; docker --version; docker compose version; echo '=== disk ==='; df -h /; echo '=== used ports ==='; docker ps --format '{{.Names}}|{{.Ports}}'; echo '=== target ==='; if [ -e " + (Quote-Sh "$DeployRoot/$AppName") + " ]; then ls -ld " + (Quote-Sh "$DeployRoot/$AppName") + "; else echo 'new application'; fi"
|
|
Invoke-Ssh $preflight -BatchMode:(-not $usingPassword)
|
|
return
|
|
}
|
|
|
|
if ($Action -eq "status") {
|
|
$projectName = "codex-$($AppName.ToLowerInvariant())"
|
|
$status = "set -eu; app_root=" + (Quote-Sh "$DeployRoot/$AppName") + "; echo '=== release ==='; if [ -L `"`$app_root/current`" ]; then readlink -f `"`$app_root/current`"; else echo 'not deployed'; fi; echo '=== containers ==='; docker ps -a --filter " + (Quote-Sh "label=com.docker.compose.project=$projectName") + " --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'; docker ps -a --filter " + (Quote-Sh "name=^/${projectName}-app$") + " --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'"
|
|
Invoke-Ssh $status -BatchMode:(-not $usingPassword)
|
|
return
|
|
}
|
|
|
|
Require-Command "scp.exe"
|
|
$remoteScript = "/tmp/codex-remote-deploy-$AppName-$([Guid]::NewGuid().ToString('N')).sh"
|
|
Invoke-Scp (Join-Path $PSScriptRoot "remote-deploy.sh") $remoteScript
|
|
|
|
if ($Action -eq "rollback") {
|
|
$command = "bash " + (Quote-Sh $remoteScript) + " rollback " + (Quote-Sh $AppName) + " '' auto " + (Quote-Sh $DeployRoot) + " '' 1 0 0; rc=`$?; rm -f " + (Quote-Sh $remoteScript) + "; exit `$rc"
|
|
Invoke-Ssh $command -BatchMode:(-not $usingPassword)
|
|
return
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($ProjectPath)) {
|
|
throw "ProjectPath is required for deploy."
|
|
}
|
|
|
|
Require-Command "tar.exe"
|
|
$resolvedProject = (Resolve-Path -LiteralPath $ProjectPath).Path
|
|
if (-not (Test-Path -LiteralPath $resolvedProject -PathType Container)) {
|
|
throw "Project path is not a directory: $resolvedProject"
|
|
}
|
|
|
|
$composeNames = @("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml")
|
|
$composeFile = $composeNames | Where-Object { Test-Path -LiteralPath (Join-Path $resolvedProject $_) } | Select-Object -First 1
|
|
$effectiveMode = $Mode
|
|
if ($Mode -eq "auto") {
|
|
if ($composeFile) {
|
|
$effectiveMode = "compose"
|
|
} elseif (Test-Path -LiteralPath (Join-Path $resolvedProject "Dockerfile")) {
|
|
$effectiveMode = "dockerfile"
|
|
} else {
|
|
throw "No Compose file or Dockerfile found in $resolvedProject"
|
|
}
|
|
}
|
|
if ($effectiveMode -eq "compose" -and -not $composeFile) {
|
|
throw "Compose mode selected, but no Compose file was found."
|
|
}
|
|
if ($effectiveMode -eq "dockerfile" -and -not (Test-Path -LiteralPath (Join-Path $resolvedProject "Dockerfile"))) {
|
|
throw "Dockerfile mode selected, but Dockerfile was not found."
|
|
}
|
|
if ($effectiveMode -eq "dockerfile" -and (($PublishPort -eq 0) -xor ($ContainerPort -eq 0))) {
|
|
throw "Provide both PublishPort and ContainerPort, or neither."
|
|
}
|
|
|
|
$tempRoot = Join-Path ([IO.Path]::GetTempPath()) "codex-company-deploy"
|
|
New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
|
|
$script:ArchivePath = Join-Path $tempRoot "$AppName-$([DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ'))-$([Guid]::NewGuid().ToString('N')).tgz"
|
|
& tar.exe -czf $script:ArchivePath --exclude=.git --exclude=node_modules --exclude=.venv --exclude=__pycache__ --exclude=.idea --exclude=.vscode --exclude=.codex -C $resolvedProject .
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Unable to create deployment archive."
|
|
}
|
|
|
|
$remoteArchive = "/tmp/$([IO.Path]::GetFileName($script:ArchivePath))"
|
|
Invoke-Scp $script:ArchivePath $remoteArchive
|
|
|
|
$skipFlag = if ($SkipHealthCheck) { "1" } else { "0" }
|
|
$commandParts = @(
|
|
"bash", (Quote-Sh $remoteScript), "deploy", (Quote-Sh $AppName),
|
|
(Quote-Sh $remoteArchive), (Quote-Sh $effectiveMode), (Quote-Sh $DeployRoot),
|
|
(Quote-Sh $HealthUrl), $skipFlag, "$PublishPort", "$ContainerPort"
|
|
)
|
|
$command = ($commandParts -join " ") + "; rc=`$?; rm -f " + (Quote-Sh $remoteScript) + " " + (Quote-Sh $remoteArchive) + "; exit `$rc"
|
|
Invoke-Ssh $command -BatchMode:(-not $usingPassword)
|
|
} finally {
|
|
$env:SSH_ASKPASS = $oldAskPass
|
|
$env:SSH_ASKPASS_REQUIRE = $oldAskPassRequire
|
|
$env:DISPLAY = $oldDisplay
|
|
if ($script:ArchivePath -and (Test-Path -LiteralPath $script:ArchivePath) -and -not $KeepArchive) {
|
|
Remove-Item -LiteralPath $script:ArchivePath -Force
|
|
}
|
|
}
|