支持飞牛在线升级场景

This commit is contained in:
WuYongJie 2026-06-02 11:48:39 +08:00
parent 97e3e6351c
commit 3b5da1c2c2
2 changed files with 126 additions and 8 deletions

View File

@ -12,6 +12,8 @@ param(
[int]$SshPort = 22, [int]$SshPort = 22,
[string]$Username = "root", [string]$Username = "root",
[SecureString]$Password = $null, [SecureString]$Password = $null,
[switch]$RemoteSudo,
[SecureString]$RemoteSudoPassword = $null,
[string]$KeyPath = "", [string]$KeyPath = "",
[string]$PlinkHostKey = "", [string]$PlinkHostKey = "",
@ -119,6 +121,7 @@ function Show-Help {
Write-Host "主要参数:" -ForegroundColor White Write-Host "主要参数:" -ForegroundColor White
Write-Host " -Server 远程服务器IP或域名;远程部署时必须显式传入" Write-Host " -Server 远程服务器IP或域名;远程部署时必须显式传入"
Write-Host " -SshPort SSH端口,默认 22;内部测试机或客户自定义端口必须显式传入" Write-Host " -SshPort SSH端口,默认 22;内部测试机或客户自定义端口必须显式传入"
Write-Host " -RemoteSudo 远端 Docker 需要 sudo 时启用;常见于飞牛 / NAS 普通管理员账号"
Write-Host " -RegistryHost Registry地址;发布和部署时必须显式传入" Write-Host " -RegistryHost Registry地址;发布和部署时必须显式传入"
Write-Host " -RegistryUsername Registry账号,默认 flnxi" Write-Host " -RegistryUsername Registry账号,默认 flnxi"
Write-Host " -RegistryPassword Registry密码,建议用 Read-Host -AsSecureString 输入" Write-Host " -RegistryPassword Registry密码,建议用 Read-Host -AsSecureString 输入"
@ -160,6 +163,14 @@ function ConvertTo-ShellSingleQuoted {
return "'" + ($Value -replace "'", "'\''") + "'" return "'" + ($Value -replace "'", "'\''") + "'"
} }
function Normalize-RemoteShellScript {
param([string]$Script)
if ($null -eq $Script) {
return ""
}
return (($Script -replace "`r`n", "`n") -replace "`r", "")
}
function Assert-Tool { function Assert-Tool {
param([string]$Name) param([string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
@ -361,6 +372,20 @@ function Ensure-SshPasswordIfNeeded {
} }
} }
function Ensure-RemoteSudoPasswordIfNeeded {
if ($SkipRemote -or -not $RemoteSudo) {
return
}
if ($RemoteSudoPassword) {
return
}
if ($Password) {
$script:RemoteSudoPassword = $Password
return
}
$script:RemoteSudoPassword = Read-Host "请输入远程 sudo 密码" -AsSecureString
}
function Test-RegistryEndpoint { function Test-RegistryEndpoint {
Assert-RegistryHostConfigured Assert-RegistryHostConfigured
Write-RegistryHttpNotice Write-RegistryHttpNotice
@ -434,13 +459,26 @@ function Invoke-RemoteCommand {
Assert-RemoteServerConfigured Assert-RemoteServerConfigured
$target = "${Username}@${Server}" $target = "${Username}@${Server}"
$remoteCommand = "bash -lc " + (ConvertTo-ShellSingleQuoted -Value $Command) $normalizedCommand = Normalize-RemoteShellScript -Script $Command
$stdinPassword = "" $remoteCommand = "bash -lc " + (ConvertTo-ShellSingleQuoted -Value $normalizedCommand)
$stdinLines = New-Object System.Collections.Generic.List[string]
if ($RemoteSudo) {
$sudoPasswordPlain = ConvertTo-PlainText -SecureValue $RemoteSudoPassword
if ([string]::IsNullOrWhiteSpace($sudoPasswordPlain)) {
throw "远程 sudo 密码为空,无法执行需要 sudo 的 Docker 命令"
}
$stdinLines.Add($sudoPasswordPlain)
}
if ((-not $SkipRegistryLogin) -and (-not $NoRegistryPasswordInput)) { if ((-not $SkipRegistryLogin) -and (-not $NoRegistryPasswordInput)) {
$stdinPassword = ConvertTo-PlainText -SecureValue $RegistryPassword $stdinRegistryPassword = ConvertTo-PlainText -SecureValue $RegistryPassword
if ([string]::IsNullOrWhiteSpace($stdinPassword)) { if ([string]::IsNullOrWhiteSpace($stdinRegistryPassword)) {
throw "Registry 密码为空,无法远程登录 Registry" throw "Registry 密码为空,无法远程登录 Registry"
} }
$stdinLines.Add($stdinRegistryPassword)
}
$stdinPayload = ""
if ($stdinLines.Count -gt 0) {
$stdinPayload = ($stdinLines -join "`n") + "`n"
} }
if ($KeyPath -and (Test-Path $KeyPath)) { if ($KeyPath -and (Test-Path $KeyPath)) {
@ -461,10 +499,10 @@ function Invoke-RemoteCommand {
Write-Host "[DRYRUN] ssh -p $SshPort $target bash -lc '<远程部署脚本,敏感信息已隐藏>'" -ForegroundColor DarkGray Write-Host "[DRYRUN] ssh -p $SshPort $target bash -lc '<远程部署脚本,敏感信息已隐藏>'" -ForegroundColor DarkGray
return return
} }
if ($SkipRegistryLogin -or $NoRegistryPasswordInput) { if ($stdinLines.Count -eq 0) {
& ssh @args & ssh @args
} else { } else {
$stdinPassword | ssh @args $stdinPayload | ssh @args
} }
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "远程部署命令失败,退出码:$LASTEXITCODE" throw "远程部署命令失败,退出码:$LASTEXITCODE"
@ -488,10 +526,10 @@ function Invoke-RemoteCommand {
Write-Host "[DRYRUN] plink -P $SshPort $target bash -lc '<远程部署脚本,敏感信息已隐藏>'" -ForegroundColor DarkGray Write-Host "[DRYRUN] plink -P $SshPort $target bash -lc '<远程部署脚本,敏感信息已隐藏>'" -ForegroundColor DarkGray
return return
} }
if ($SkipRegistryLogin -or $NoRegistryPasswordInput) { if ($stdinLines.Count -eq 0) {
& plink.exe @args & plink.exe @args
} else { } else {
$stdinPassword | plink.exe @args $stdinPayload | plink.exe @args
} }
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "远程部署命令失败,退出码:$LASTEXITCODE" throw "远程部署命令失败,退出码:$LASTEXITCODE"
@ -522,9 +560,13 @@ function New-RemoteDeployScript {
$remoteObjectBackupEnabled = if ($NoRemoteObjectStorageBackup) { "0" } else { "1" } $remoteObjectBackupEnabled = if ($NoRemoteObjectStorageBackup) { "0" } else { "1" }
$externalDataBackupConfirmed = if ($ExternalDataBackupConfirmed) { "1" } else { "0" } $externalDataBackupConfirmed = if ($ExternalDataBackupConfirmed) { "1" } else { "0" }
$rollbackEnabled = if ($NoRollback) { "0" } else { "1" } $rollbackEnabled = if ($NoRollback) { "0" } else { "1" }
$remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" }
$lines = New-Object System.Collections.Generic.List[string] $lines = New-Object System.Collections.Generic.List[string]
$lines.Add("set -Eeuo pipefail") $lines.Add("set -Eeuo pipefail")
if ($RemoteSudo) {
$lines.Add("IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }")
}
if (-not $SkipRegistryLogin) { if (-not $SkipRegistryLogin) {
$lines.Add("IFS= read -r REGISTRY_PASSWORD || { echo '未收到 Registry 密码'; exit 22; }") $lines.Add("IFS= read -r REGISTRY_PASSWORD || { echo '未收到 Registry 密码'; exit 22; }")
} }
@ -541,6 +583,7 @@ function New-RemoteDeployScript {
$lines.Add("external_data_backup_confirmed=$externalDataBackupConfirmed") $lines.Add("external_data_backup_confirmed=$externalDataBackupConfirmed")
$lines.Add("remote_backup_helper_image=$quotedRemoteBackupHelperImage") $lines.Add("remote_backup_helper_image=$quotedRemoteBackupHelperImage")
$lines.Add("rollback_enabled=$rollbackEnabled") $lines.Add("rollback_enabled=$rollbackEnabled")
$lines.Add("remote_sudo_enabled=$remoteSudoEnabled")
$lines.Add("export APP_IMAGE=$quotedRegistryImage") $lines.Add("export APP_IMAGE=$quotedRegistryImage")
$lines.Add("export NEXTAUTH_URL=$quotedNextAuthUrl") $lines.Add("export NEXTAUTH_URL=$quotedNextAuthUrl")
$lines.Add("export APP_PUBLIC_URL=$quotedAppPublicUrl") $lines.Add("export APP_PUBLIC_URL=$quotedAppPublicUrl")
@ -558,6 +601,21 @@ db_backup=""
object_backup="" object_backup=""
minio_volume_name="" minio_volume_name=""
sudo_auth() {
if [ "${remote_sudo_enabled:-0}" = "1" ]; then
printf '%s\n' "$SUDO_PASSWORD" | sudo -S -p '' true
fi
}
docker() {
if [ "${remote_sudo_enabled:-0}" = "1" ]; then
sudo_auth
sudo -n docker "$@"
else
command docker "$@"
fi
}
upsert_env() { upsert_env() {
key="$1" key="$1"
value="$2" value="$2"
@ -576,6 +634,7 @@ read_env_value() {
awk -F= -v k="$key" ' awk -F= -v k="$key" '
index($0, k "=") == 1 { index($0, k "=") == 1 {
value=substr($0, length(k) + 2) value=substr($0, length(k) + 2)
gsub(/\r$/, "", value)
gsub(/^"|"$/, "", value) gsub(/^"|"$/, "", value)
gsub(/^'\''|'\''$/, "", value) gsub(/^'\''|'\''$/, "", value)
print value print value
@ -908,9 +967,26 @@ function New-RemoteRollbackScript {
$quotedRemotePath = ConvertTo-ShellSingleQuoted -Value $RemotePath $quotedRemotePath = ConvertTo-ShellSingleQuoted -Value $RemotePath
$quotedReason = ConvertTo-ShellSingleQuoted -Value $Reason $quotedReason = ConvertTo-ShellSingleQuoted -Value $Reason
$remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" }
$sudoReadLine = if ($RemoteSudo) { "IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }" } else { "" }
return @" return @"
set -Eeuo pipefail set -Eeuo pipefail
$sudoReadLine
remote_sudo_enabled=$remoteSudoEnabled
sudo_auth() {
if [ "`${remote_sudo_enabled:-0}" = "1" ]; then
printf '%s\n' "`$SUDO_PASSWORD" | sudo -S -p '' true
fi
}
docker() {
if [ "`${remote_sudo_enabled:-0}" = "1" ]; then
sudo_auth
sudo -n docker "`$@"
else
command docker "`$@"
fi
}
cd $quotedRemotePath cd $quotedRemotePath
reason=$quotedReason reason=$quotedReason
context_file="./backups/LAST_DEPLOY_CONTEXT.env" context_file="./backups/LAST_DEPLOY_CONTEXT.env"
@ -1072,6 +1148,7 @@ function Invoke-DeployApp {
Ensure-RegistryPassword Ensure-RegistryPassword
Ensure-SshPasswordIfNeeded Ensure-SshPasswordIfNeeded
Ensure-RemoteSudoPasswordIfNeeded
Invoke-ProjectCheck Invoke-ProjectCheck
Test-RegistryEndpoint Test-RegistryEndpoint
$registryImage = Invoke-PublishAppImage -VersionTag $deployTag -Repository $deployRepository -PushLatest:$false $registryImage = Invoke-PublishAppImage -VersionTag $deployTag -Repository $deployRepository -PushLatest:$false
@ -1304,8 +1381,26 @@ function Invoke-ShowStatus {
if (-not $SkipRemote) { if (-not $SkipRemote) {
Ensure-SshPasswordIfNeeded Ensure-SshPasswordIfNeeded
Ensure-RemoteSudoPasswordIfNeeded
$remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" }
$sudoReadLine = if ($RemoteSudo) { "IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }" } else { "" }
$remoteStatus = @" $remoteStatus = @"
set -e set -e
$sudoReadLine
remote_sudo_enabled=$remoteSudoEnabled
sudo_auth() {
if [ "`${remote_sudo_enabled:-0}" = "1" ]; then
printf '%s\n' "`$SUDO_PASSWORD" | sudo -S -p '' true
fi
}
docker() {
if [ "`${remote_sudo_enabled:-0}" = "1" ]; then
sudo_auth
sudo -n docker "`$@"
else
command docker "`$@"
fi
}
echo '--- docker ps ---' echo '--- docker ps ---'
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' | grep -E 'flnxi|postgres|minio|license' || true docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' | grep -E 'flnxi|postgres|minio|license' || true
echo '--- compose ---' echo '--- compose ---'

View File

@ -81,4 +81,27 @@ describe('deploy-registry.ps1', () => {
expect(script).toContain('$lines.Add(\'docker compose -f "$compose_file" ps\')') expect(script).toContain('$lines.Add(\'docker compose -f "$compose_file" ps\')')
expect(script).not.toContain('$lines.Add("docker compose -f `"$compose_file`"') expect(script).not.toContain('$lines.Add("docker compose -f `"$compose_file`"')
}) })
it('支持飞牛/NAS 普通管理员通过 sudo 执行远程 Docker 升级', () => {
expect(script).toContain('[switch]$RemoteSudo')
expect(script).toContain('[SecureString]$RemoteSudoPassword = $null')
expect(script).toContain('function Ensure-RemoteSudoPasswordIfNeeded')
expect(script).toContain('$script:RemoteSudoPassword = $Password')
expect(script).toContain('remote_sudo_enabled=$remoteSudoEnabled')
expect(script).toContain('IFS= read -r SUDO_PASSWORD')
expect(script).toContain('sudo -n docker "$@"')
expect(script).toContain('sudo -n docker "`$@"')
expect(script).toContain('Invoke-RemoteCommand -Command $rollbackScript -NoRegistryPasswordInput')
})
it('发送到 Linux 的远程脚本会统一去除 Windows 回车符', () => {
expect(script).toContain('function Normalize-RemoteShellScript')
expect(script).toContain('($Script -replace "`r`n", "`n") -replace "`r", ""')
expect(script).toContain('$normalizedCommand = Normalize-RemoteShellScript -Script $Command')
expect(script).toContain('ConvertTo-ShellSingleQuoted -Value $normalizedCommand')
})
it('读取远端 .env 时会去除 Windows 行尾回车', () => {
expect(script).toContain('gsub(/\\r$/, "", value)')
})
}) })