支持飞牛在线升级场景

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