增加需确认的全新部署流程

默认升级保留 Smail 运行数据;Linux 和 Windows 的全新部署均要求输入确认词,先备份后仅清理 Smail 专属目录,并补充部署策略回归用例。
This commit is contained in:
smail 2026-10-01 11:48:03 +08:00
parent 0c4ad939aa
commit 9eabdd2049
4 changed files with 168 additions and 39 deletions

View File

@ -96,6 +96,29 @@ func TestInstallerManagesProtectedEnvironmentFile(t *testing.T) {
}
}
func TestInstallerRequiresExplicitFreshReset(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
installer, err := os.ReadFile(filepath.Join(root, "deploy", "install-systemd.sh"))
if err != nil {
t.Fatal(err)
}
content := string(installer)
for _, required := range []string{
"--fresh",
"确认全新部署 Smail",
"cp -a -- \"$runtime_dir/.\" \"$backup_dir/runtime/\"",
"find \"$runtime_dir\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
"systemctl disable smail smail-admin",
} {
if !strings.Contains(content, required) {
t.Fatalf("installer is missing explicit fresh-reset behavior %q", required)
}
}
if strings.Contains(content, "全新部署已停止:$target 非空") {
t.Fatal("installer must not reject existing data without an explicit fresh-reset request")
}
}
func TestSmailServiceRetainsPluginIsolationCapabilities(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
unit, err := os.ReadFile(filepath.Join(root, "deploy", "systemd", "smail.service"))

View File

@ -50,6 +50,29 @@ func TestWindowsDeploymentScriptsKeepAdminChannelManual(t *testing.T) {
}
}
func TestWindowsInstallerRequiresExplicitFreshReset(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
installer, err := os.ReadFile(filepath.Join(root, "deploy", "install-windows.ps1"))
if err != nil {
t.Fatal(err)
}
content := string(installer)
for _, required := range []string{
"[switch]$Fresh",
"确认全新部署 Smail",
".smail-fresh-backup-",
"Copy-Item -LiteralPath $item.FullName -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force",
"Remove-Item -LiteralPath $item.FullName -Recurse -Force",
} {
if !strings.Contains(content, required) {
t.Fatalf("Windows installer is missing explicit fresh-reset behavior %q", required)
}
}
if strings.Contains(content, "全新部署已停止:$existing 不是空目录") {
t.Fatal("Windows installer must not reject existing data without an explicit fresh-reset request")
}
}
func hasUTF8BOM(data []byte) bool {
return len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF
}

View File

@ -1,13 +1,53 @@
#!/usr/bin/env bash
set -eu
usage() {
echo "用法:$0 [--fresh] [发布目录]"
echo "默认模式保留配置、数据库、证书、日志和插件;--fresh 模式必须输入确认词后才会备份并清理 Smail 专属运行数据。"
}
fresh=false
root_argument=""
while [ "$#" -gt 0 ]; do
case "$1" in
--fresh)
fresh=true
;;
--help|-h)
usage
exit 0
;;
-* )
echo "未知参数:$1" >&2
usage >&2
exit 1
;;
*)
if [ -n "$root_argument" ]; then
echo "发布目录只能指定一次。" >&2
exit 1
fi
root_argument="$1"
;;
esac
shift
done
if [ "$(id -u)" -ne 0 ]; then
echo "必须使用 root 用户安装 Smail" >&2
exit 1
fi
root_dir="$(realpath -e -- "${1:-$(dirname "$0")/..}")"
root_dir="$(realpath -e -- "${root_argument:-$(dirname "$0")/..}")"
runtime_dir=/opt/smail
fresh_confirmation="确认全新部署 Smail"
if [ "$fresh" = true ]; then
printf '这是不可逆的 Smail 专属数据清理操作。请输入“%s”继续: ' "$fresh_confirmation"
if ! IFS= read -r confirmation || [ "$confirmation" != "$fresh_confirmation" ]; then
echo "未获得有效确认,已取消全新部署。" >&2
exit 1
fi
fi
case "$(uname -m)" in
x86_64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
@ -66,31 +106,48 @@ if [ -L /run/smail/admin-control.sock ] || { [ -e /run/smail/admin-control.sock
exit 1
fi
# 全新安装不继承目标目录中的配置、证书或业务数据,也不自动清理它们。
for directory in config data ssl log plugins; do
target="$runtime_dir/$directory"
if [ -d "$target" ] && [ -n "$(find "$target" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "全新部署已停止:$target 非空。请先确认并处理旧数据,再通过初始化页面重新配置。" >&2
exit 1
fi
done
for file in config.json config.dev.json smail.db smail-state.db; do
if [ -e "$runtime_dir/$file" ] || [ -L "$runtime_dir/$file" ]; then
echo "全新部署已停止:发现历史运行文件 $file,不会自动继承或删除。" >&2
exit 1
fi
done
if [ -e /etc/smail/smail.env ]; then
echo "全新部署已停止:请先确认并处理 /etc/smail/smail.env 中的旧部署环境配置。" >&2
exit 1
fi
if [ -d /etc/systemd/system/smail.service.d ] && [ -n "$(find /etc/systemd/system/smail.service.d -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "全新部署已停止:请先确认并处理 Smail 的旧服务覆盖配置。" >&2
exit 1
fi
# 安装备份存放在系统备份目录,不在部署根目录遗留 .bak 文件。
backup_dir="$(mktemp -d /var/backups/smail-deploy-XXXXXXXX)"
chmod 0700 "$backup_dir"
if [ "$fresh" = true ]; then
if [ -d "$runtime_dir" ] && [ -n "$(find "$runtime_dir" -mindepth 1 -maxdepth 1 -type l -print -quit)" ]; then
echo "全新部署已停止:/opt/smail 顶层存在符号链接,拒绝清理。" >&2
exit 1
fi
install -d -m 0700 "$backup_dir/runtime"
if [ -d "$runtime_dir" ]; then
cp -a -- "$runtime_dir/." "$backup_dir/runtime/"
fi
if [ -d /etc/smail ]; then
cp -a -- /etc/smail "$backup_dir/etc-smail"
fi
if [ -d /etc/systemd/system/smail.service.d ]; then
cp -a -- /etc/systemd/system/smail.service.d "$backup_dir/smail.service.d"
fi
for name in smail.service smail-admin.service; do
if [ -f "/etc/systemd/system/$name" ]; then
cp -p -- "/etc/systemd/system/$name" "$backup_dir/$name"
fi
done
if [ -e /etc/smail/smail.env ]; then
cp -p -- /etc/smail/smail.env "$backup_dir/smail.env"
fi
if [ -d "$runtime_dir" ]; then
find "$runtime_dir" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
fi
if [ -d /etc/smail ]; then
find /etc/smail -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
fi
if [ -d /etc/systemd/system/smail.service.d ]; then
rm -rf -- /etc/systemd/system/smail.service.d
fi
if [ -d /run/smail ]; then
find /run/smail -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
fi
systemctl disable smail smail-admin >/dev/null 2>&1 || true
systemctl daemon-reload
echo "已完成 Smail 专属运行数据清理;完整备份保存在 $backup_dir。"
fi
for name in smail smail-admin; do
if [ -f "$runtime_dir/$name" ]; then cp -p -- "$runtime_dir/$name" "$backup_dir/$name"; fi
done

View File

@ -2,6 +2,7 @@
param(
[string]$PackageRoot = $null,
[string]$InstallRoot = (Join-Path $env:ProgramData "Smail"),
[switch]$Fresh,
[switch]$StartService
)
@ -99,6 +100,16 @@ function Stop-SmailService {
}
Assert-Administrator
if ($Fresh -and $StartService) {
throw "全新部署必须先完成初始化配置,不能在安装时启动服务"
}
if ($Fresh) {
$freshConfirmation = "确认全新部署 Smail"
$confirmation = Read-Host "这是不可逆的 Smail 专属数据清理操作。请输入“$freshConfirmation”继续"
if ($confirmation -cne $freshConfirmation) {
throw "未获得有效确认,已取消全新部署"
}
}
$mainSource = Resolve-PackageFile -Candidates @(
(Join-Path $PackageRoot "out\backend\smail_windows_amd64.exe"),
(Join-Path $PackageRoot "backend\smail_windows_amd64.exe"),
@ -121,27 +132,38 @@ if (Test-Path -LiteralPath $resolvedInstallRoot) {
throw "安装目录不能是文件或链接"
}
}
foreach ($name in @('config', 'data', 'ssl', 'log', 'plugins')) {
$existing = Join-Path $resolvedInstallRoot $name
if (-not (Test-Path -LiteralPath $existing)) { continue }
$info = Get-Item -LiteralPath $existing -Force
if (-not $info.PSIsContainer -or $info.Attributes -band [IO.FileAttributes]::ReparsePoint -or
(Get-ChildItem -LiteralPath $existing -Force | Select-Object -First 1)) {
throw "全新部署已停止:$existing 不是空目录。请先确认并处理旧数据,再通过初始化页面重新配置。"
}
}
foreach ($name in @('config.json', 'config.dev.json', 'smail.db', 'smail-state.db')) {
if (Test-Path -LiteralPath (Join-Path $resolvedInstallRoot $name)) {
throw "全新部署已停止:发现历史运行文件 $name,不会自动继承或删除。"
if ($Fresh -and (Test-Path -LiteralPath $resolvedInstallRoot)) {
foreach ($item in Get-ChildItem -LiteralPath $resolvedInstallRoot -Force) {
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
throw "全新部署已停止:安装目录顶层存在重解析点,拒绝清理:$($item.FullName)"
}
}
}
New-Item -ItemType Directory -Path $resolvedInstallRoot -Force | Out-Null
if ($Fresh) {
Stop-SmailService
$backupRoot = Join-Path ([IO.Path]::GetDirectoryName($resolvedInstallRoot)) (".smail-fresh-backup-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null
if (Test-Path -LiteralPath $resolvedInstallRoot) {
foreach ($item in Get-ChildItem -LiteralPath $resolvedInstallRoot -Force) {
Copy-Item -LiteralPath $item.FullName -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force
}
foreach ($item in Get-ChildItem -LiteralPath $resolvedInstallRoot -Force) {
Remove-Item -LiteralPath $item.FullName -Recurse -Force
}
}
Write-Output "已完成 Smail 专属运行数据清理;完整备份保存在 $backupRoot。"
} else {
$backupRoot = Join-Path $resolvedInstallRoot (".backup-" + [Guid]::NewGuid().ToString("N"))
}
New-Item -ItemType Directory -Path (Join-Path $resolvedInstallRoot "config") -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $resolvedInstallRoot "data") -Force | Out-Null
$transactionRoot = Join-Path $resolvedInstallRoot (".upgrade-" + [Guid]::NewGuid().ToString("N"))
$backupRoot = Join-Path $resolvedInstallRoot (".backup-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $transactionRoot -Force | Out-Null
New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null
if (-not $Fresh) {
$backupRoot = Join-Path $resolvedInstallRoot (".backup-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null
}
$mainTarget = Join-Path $resolvedInstallRoot "smail.exe"
$adminTarget = Join-Path $resolvedInstallRoot "smail-admin.exe"
@ -214,7 +236,11 @@ try {
throw
}
foreach ($temporaryRoot in @($transactionRoot, $backupRoot)) {
$temporaryRoots = @($transactionRoot)
if (-not $Fresh) {
$temporaryRoots += $backupRoot
}
foreach ($temporaryRoot in $temporaryRoots) {
if (Test-Path -LiteralPath $temporaryRoot) {
$resolvedTemporaryRoot = (Resolve-Path -LiteralPath $temporaryRoot).Path
if ([IO.Path]::GetDirectoryName($resolvedTemporaryRoot) -ne $resolvedInstallRoot.TrimEnd('\')) {