fix(发布): 收紧备份与部署安全边界
This commit is contained in:
parent
bc7d3cca17
commit
7cdbe3a507
4
.gitignore
vendored
4
.gitignore
vendored
@ -26,7 +26,6 @@ migration_manual_overrides.json
|
||||
04-borrow-management.png
|
||||
05-settings-page.png
|
||||
nul
|
||||
{
|
||||
.dotnet8/
|
||||
print-agent-dotnet/**/bin/
|
||||
print-agent-dotnet/**/obj/
|
||||
@ -39,3 +38,6 @@ temp_db_purchase_dates.tsv
|
||||
codex_release_files.txt
|
||||
dingtalk-requests.txt
|
||||
行政手动处理清单*.xlsx
|
||||
temp/
|
||||
temp_reports/
|
||||
对话续接摘要_*.json
|
||||
|
||||
@ -25,6 +25,14 @@ chmod +x /opt/equipment-management/scripts/backup-equipment.sh
|
||||
KEEP_BACKUPS=5 /opt/equipment-management/scripts/backup-equipment.sh
|
||||
```
|
||||
|
||||
脚本不包含数据库密码默认值。密码读取顺序为:
|
||||
|
||||
1. 当前进程的 `DB_PASSWORD`
|
||||
2. 当前进程的 `MYSQL_ROOT_PASSWORD`
|
||||
3. 只读获取 `equipment-mysql` 容器环境中的 `MYSQL_ROOT_PASSWORD`
|
||||
|
||||
三种方式都无法取得密码时,备份会明确失败,不会继续生成不完整备份,也不会把密码打印到日志。
|
||||
|
||||
## 备份内容
|
||||
|
||||
每次执行会生成一个时间戳目录,包含:
|
||||
|
||||
17
README.md
17
README.md
@ -74,20 +74,31 @@ $env:DEPLOY_PASSWORD="<部署密码>"
|
||||
|
||||
这个脚本会自动完成:
|
||||
|
||||
- 打包当前仓库源码
|
||||
- 检查 Git 工作树,默认拒绝发布未提交改动
|
||||
- 只打包后端、前端、下载清单和部署脚本等白名单文件
|
||||
- 校验下载清单引用的打印助手/驱动安装包,并把对应二进制纳入发布包
|
||||
- 上传到 `/opt/equipment-management`
|
||||
- 只覆盖固定资产系统源码
|
||||
- 校验发布包并保留服务器上的自定义品牌文件
|
||||
- 只覆盖固定资产系统源码,不处理 `shebeiguanli-*`
|
||||
- 调用服务器上的安全部署脚本
|
||||
|
||||
常用参数:
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-to-server.ps1 -DryRun
|
||||
.\scripts\publish-to-server.ps1 -SkipDeploy
|
||||
.\scripts\publish-to-server.ps1 -SkipBackup
|
||||
.\scripts\publish-to-server.ps1 -DryRun -AllowDirty
|
||||
```
|
||||
|
||||
- `-DryRun`:只显示发布文件数量、体积和白名单,不创建压缩包、不连接服务器
|
||||
- `-SkipDeploy`:只同步代码,不执行服务器部署
|
||||
- `-SkipBackup`:执行部署,但跳过备份,不建议常用
|
||||
- `-AllowDirty`:显式允许应急发布未提交改动;仍只包含白名单文件,常规发布禁止使用
|
||||
|
||||
远端目录被强制锁定为 `/opt/equipment-management`,远端临时压缩包被锁定在 `/tmp/equipment-management-release.tar.gz`;即使误传参数,也不能覆盖 `/opt/shebeiguanli` 或其他目录。下载清单中 `server_release` / `company_archive` 类型引用的本地文件只要缺失,发布会在连接服务器前直接失败。
|
||||
|
||||
密码模式使用本机的 `plink` / `pscp`。建议设置 `DEPLOY_HOST_KEY` 固定服务器主机指纹;如果未设置,PuTTY 必须已经缓存并信任该主机密钥,否则批处理模式会安全失败。使用 SSH 密钥时走系统 OpenSSH,并使用 `StrictHostKeyChecking=accept-new`,不会再无条件关闭主机校验。
|
||||
|
||||
## 备份与回滚
|
||||
|
||||
@ -100,6 +111,8 @@ cd /opt/equipment-management
|
||||
|
||||
默认只保留最近 `3` 份备份,备份目录在 `/opt/backups/equipment-management`。详细说明见 `BACKUP.md`。
|
||||
|
||||
备份脚本不再提供数据库密码默认值。它会优先读取 `DB_PASSWORD` / `MYSQL_ROOT_PASSWORD`,未设置时只读获取 `equipment-mysql` 容器中的 `MYSQL_ROOT_PASSWORD`;仍无法取得时会直接终止备份,并且不会输出密码内容。
|
||||
|
||||
## 人员与组织同步
|
||||
|
||||
后端已预留钉钉中台同步能力,支持同步:
|
||||
|
||||
128
deploy.sh
128
deploy.sh
@ -8,8 +8,86 @@ COMPOSE_FILE="$PROJECT_DIR/docker-compose.yml"
|
||||
PROJECT_NAME="equipment-management"
|
||||
BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh"
|
||||
SKIP_BACKUP="${SKIP_BACKUP:-0}"
|
||||
HEALTHCHECK_ATTEMPTS="${HEALTHCHECK_ATTEMPTS:-12}"
|
||||
HEALTHCHECK_INTERVAL_SECONDS="${HEALTHCHECK_INTERVAL_SECONDS:-5}"
|
||||
HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://localhost:3000/api/health}"
|
||||
EQUIPMENT_CONTAINERS=("equipment-frontend" "equipment-backend" "equipment-mysql")
|
||||
LEGACY_CONTAINERS=("shebeiguanli-frontend" "shebeiguanli-backend" "shebeiguanli-mysql")
|
||||
LEGACY_STATUS_BEFORE=()
|
||||
|
||||
get_container_status() {
|
||||
local container="$1"
|
||||
docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || printf 'missing\n'
|
||||
}
|
||||
|
||||
record_legacy_container_statuses() {
|
||||
local index container status
|
||||
|
||||
echo ">>> 记录摄影设备借还系统容器状态(仅检查,不操作)..."
|
||||
for index in "${!LEGACY_CONTAINERS[@]}"; do
|
||||
container="${LEGACY_CONTAINERS[$index]}"
|
||||
status="$(get_container_status "$container")"
|
||||
LEGACY_STATUS_BEFORE[$index]="$status"
|
||||
echo " $container => $status"
|
||||
done
|
||||
}
|
||||
|
||||
verify_legacy_container_statuses() {
|
||||
local index container before_status after_status
|
||||
local failed=0
|
||||
|
||||
echo ">>> 确认摄影设备借还系统未受影响(仅检查,不操作)..."
|
||||
for index in "${!LEGACY_CONTAINERS[@]}"; do
|
||||
container="${LEGACY_CONTAINERS[$index]}"
|
||||
before_status="${LEGACY_STATUS_BEFORE[$index]}"
|
||||
after_status="$(get_container_status "$container")"
|
||||
echo " $container => 部署前: $before_status,部署后: $after_status"
|
||||
|
||||
if [ "$before_status" != "missing" ] && [ "$after_status" != "$before_status" ]; then
|
||||
echo " 错误:$container 状态发生变化。部署脚本未对该容器执行任何操作,请立即排查。" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
verify_equipment_containers() {
|
||||
local container status
|
||||
local failed=0
|
||||
|
||||
echo ">>> 固定资产系统容器状态..."
|
||||
for container in "${EQUIPMENT_CONTAINERS[@]}"; do
|
||||
status="$(get_container_status "$container")"
|
||||
echo " $container => $status"
|
||||
if [ "$status" != "running" ]; then
|
||||
echo " 错误:固定资产容器 $container 未处于 running 状态。" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
wait_for_backend_health() {
|
||||
local attempt
|
||||
|
||||
echo ">>> 检查固定资产系统后端健康状态..."
|
||||
for ((attempt = 1; attempt <= HEALTHCHECK_ATTEMPTS; attempt++)); do
|
||||
if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
|
||||
echo " 后端健康检查通过(第 ${attempt}/${HEALTHCHECK_ATTEMPTS} 次)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$HEALTHCHECK_ATTEMPTS" ]; then
|
||||
echo " 第 ${attempt}/${HEALTHCHECK_ATTEMPTS} 次检查失败,${HEALTHCHECK_INTERVAL_SECONDS} 秒后重试..."
|
||||
sleep "$HEALTHCHECK_INTERVAL_SECONDS"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "后端健康检查失败:${HEALTHCHECK_URL},已尝试 ${HEALTHCHECK_ATTEMPTS} 次。" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE")
|
||||
@ -34,8 +112,20 @@ if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$HEALTHCHECK_ATTEMPTS" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "HEALTHCHECK_ATTEMPTS 必须是正整数: $HEALTHCHECK_ATTEMPTS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$HEALTHCHECK_INTERVAL_SECONDS" =~ ^[0-9]+$ ]]; then
|
||||
echo "HEALTHCHECK_INTERVAL_SECONDS 必须是非负整数: $HEALTHCHECK_INTERVAL_SECONDS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
record_legacy_container_statuses
|
||||
|
||||
if [ "$SKIP_BACKUP" = "1" ]; then
|
||||
echo ">>> 已设置 SKIP_BACKUP=1,跳过备份"
|
||||
else
|
||||
@ -48,36 +138,30 @@ else
|
||||
"$BACKUP_SCRIPT"
|
||||
fi
|
||||
|
||||
echo ">>> 部署前检查旧系统容器状态..."
|
||||
for container in "${LEGACY_CONTAINERS[@]}"; do
|
||||
status=$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || echo "missing")
|
||||
echo " $container => $status"
|
||||
done
|
||||
|
||||
echo ">>> 先构建固定资产系统镜像..."
|
||||
"${COMPOSE_CMD[@]}" build
|
||||
|
||||
echo ">>> 镜像构建成功,开始更新固定资产系统容器..."
|
||||
"${COMPOSE_CMD[@]}" down
|
||||
"${COMPOSE_CMD[@]}" up -d
|
||||
"${COMPOSE_CMD[@]}" up -d --remove-orphans
|
||||
|
||||
echo ">>> 等待服务启动..."
|
||||
sleep 10
|
||||
deployment_failed=0
|
||||
|
||||
echo ">>> 固定资产系统容器状态..."
|
||||
for container in "${EQUIPMENT_CONTAINERS[@]}"; do
|
||||
status=$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || echo "missing")
|
||||
echo " $container => $status"
|
||||
done
|
||||
if ! wait_for_backend_health; then
|
||||
deployment_failed=1
|
||||
fi
|
||||
|
||||
echo ">>> 再次确认摄影设备借还系统未受影响..."
|
||||
for container in "${LEGACY_CONTAINERS[@]}"; do
|
||||
status=$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || echo "missing")
|
||||
echo " $container => $status"
|
||||
done
|
||||
if ! verify_equipment_containers; then
|
||||
deployment_failed=1
|
||||
fi
|
||||
|
||||
echo ">>> 检查固定资产系统后端健康状态..."
|
||||
curl -fsS http://localhost:3000/api/health || echo "后端服务启动中..."
|
||||
if ! verify_legacy_container_statuses; then
|
||||
deployment_failed=1
|
||||
fi
|
||||
|
||||
if [ "$deployment_failed" -ne 0 ]; then
|
||||
echo "固定资产系统部署校验失败,请根据上述错误排查。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
|
||||
@ -17,7 +17,7 @@ services:
|
||||
- mysql_data:/var/lib/mysql
|
||||
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p\"$MYSQL_ROOT_PASSWORD\""]
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p\"$$MYSQL_ROOT_PASSWORD\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@ -11,14 +11,11 @@ DB_CONTAINER="${DB_CONTAINER:-equipment-mysql}"
|
||||
BACKEND_CONTAINER="${BACKEND_CONTAINER:-equipment-backend}"
|
||||
DB_NAME="${DB_NAME:-equipment_management}"
|
||||
DB_USER="${DB_USER:-root}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-jsjj8888}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-${MYSQL_ROOT_PASSWORD:-}}"
|
||||
UPLOADS_PATH="${UPLOADS_PATH:-/app/uploads}"
|
||||
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
BACKUP_DIR="${BACKUP_ROOT}/${TIMESTAMP}"
|
||||
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
mkdir -p "${BACKUP_DIR}/db" "${BACKUP_DIR}/uploads" "${BACKUP_DIR}/config"
|
||||
|
||||
cleanup_on_error() {
|
||||
echo "备份失败,清理不完整目录: ${BACKUP_DIR}"
|
||||
rm -rf "${BACKUP_DIR}"
|
||||
@ -55,9 +52,6 @@ prune_old_backups() {
|
||||
|
||||
trap cleanup_on_error ERR
|
||||
|
||||
echo "备份目录: ${BACKUP_DIR}"
|
||||
echo "保留策略: 最近 ${KEEP_BACKUPS} 份"
|
||||
|
||||
if ! docker inspect "${DB_CONTAINER}" >/dev/null 2>&1; then
|
||||
echo "数据库容器不存在: ${DB_CONTAINER}"
|
||||
exit 1
|
||||
@ -68,9 +62,37 @@ if ! docker inspect "${BACKEND_CONTAINER}" >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${DB_PASSWORD}" ]; then
|
||||
echo ">>> 未从 DB_PASSWORD / MYSQL_ROOT_PASSWORD 读取到数据库密码,尝试读取 ${DB_CONTAINER} 容器环境"
|
||||
if ! container_env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "${DB_CONTAINER}")"; then
|
||||
echo "无法读取数据库容器环境: ${DB_CONTAINER}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r env_line; do
|
||||
case "${env_line}" in
|
||||
MYSQL_ROOT_PASSWORD=*)
|
||||
DB_PASSWORD="${env_line#MYSQL_ROOT_PASSWORD=}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done <<< "${container_env}"
|
||||
unset container_env env_line
|
||||
fi
|
||||
|
||||
if [ -z "${DB_PASSWORD}" ]; then
|
||||
echo "数据库密码缺失:请设置 DB_PASSWORD 或 MYSQL_ROOT_PASSWORD;容器 ${DB_CONTAINER} 中也未找到 MYSQL_ROOT_PASSWORD。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${BACKUP_DIR}/db" "${BACKUP_DIR}/uploads" "${BACKUP_DIR}/config"
|
||||
|
||||
echo "备份目录: ${BACKUP_DIR}"
|
||||
echo "保留策略: 最近 ${KEEP_BACKUPS} 份"
|
||||
|
||||
echo ">>> 备份数据库 ${DB_NAME}"
|
||||
docker exec "${DB_CONTAINER}" \
|
||||
sh -lc "exec mysqldump -u${DB_USER} -p'${DB_PASSWORD}' --single-transaction --routines --triggers ${DB_NAME}" \
|
||||
docker exec -e "MYSQL_PWD=${DB_PASSWORD}" "${DB_CONTAINER}" \
|
||||
mysqldump -u"${DB_USER}" --single-transaction --routines --triggers "${DB_NAME}" \
|
||||
| gzip > "${BACKUP_DIR}/db/${DB_NAME}.sql.gz"
|
||||
|
||||
echo ">>> 备份上传目录 ${UPLOADS_PATH}"
|
||||
|
||||
@ -1,27 +1,41 @@
|
||||
param(
|
||||
[string]$HostName = "",
|
||||
[string]$User = "root",
|
||||
[int]$Port = 22,
|
||||
[string]$RemoteDir = "/opt/equipment-management",
|
||||
[string]$RemoteArchive = "/tmp/equipment-management-release.tar.gz",
|
||||
[switch]$SkipDeploy,
|
||||
[switch]$SkipBackup
|
||||
[switch]$SkipBackup,
|
||||
[switch]$AllowDirty,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not $HostName -and $env:DEPLOY_HOST) {
|
||||
$HostName = $env:DEPLOY_HOST
|
||||
}
|
||||
|
||||
if (-not $HostName) {
|
||||
throw "请通过 -HostName 或 DEPLOY_HOST 提供部署服务器地址。"
|
||||
}
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[Console]::OutputEncoding = $utf8NoBom
|
||||
$OutputEncoding = $utf8NoBom
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$password = if ($env:DEPLOY_PASSWORD) { $env:DEPLOY_PASSWORD } else { "" }
|
||||
$hostKey = if ($env:DEPLOY_HOST_KEY) { $env:DEPLOY_HOST_KEY } else { "" }
|
||||
$archiveName = "equipment-management-release.tar.gz"
|
||||
$archivePath = Join-Path ([System.IO.Path]::GetTempPath()) $archiveName
|
||||
$fileListPath = Join-Path ([System.IO.Path]::GetTempPath()) "equipment-management-files.txt"
|
||||
$expectedRemoteDir = "/opt/equipment-management"
|
||||
$expectedRemoteArchive = "/tmp/equipment-management-release.tar.gz"
|
||||
$releasePaths = @(
|
||||
"backend",
|
||||
"frontend",
|
||||
"downloads",
|
||||
"scripts/backup-equipment.sh",
|
||||
".env.example",
|
||||
"AGENTS.md",
|
||||
"BACKUP.md",
|
||||
"Dockerfile",
|
||||
"README.md",
|
||||
"deploy.sh",
|
||||
"docker-compose.yml"
|
||||
)
|
||||
|
||||
function Get-CommandPath([string]$name) {
|
||||
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||
@ -35,81 +49,296 @@ function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$IgnoreExitCode
|
||||
[string]$Description = "External command"
|
||||
)
|
||||
|
||||
& $FilePath @Arguments
|
||||
if (-not $IgnoreExitCode -and $LASTEXITCODE -ne 0) {
|
||||
throw "Command failed: $FilePath $($Arguments -join ' ')"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Description failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
$tarExe = Get-CommandPath "tar"
|
||||
$sshExe = Get-CommandPath "ssh"
|
||||
$scpExe = Get-CommandPath "scp"
|
||||
$sshpassExe = $null
|
||||
function ConvertTo-ShellLiteral([string]$value) {
|
||||
if ($value.Contains("'") -or $value.Contains([char]13) -or $value.Contains([char]10)) {
|
||||
throw "Remote paths cannot contain single quotes or line breaks."
|
||||
}
|
||||
return "'$value'"
|
||||
}
|
||||
|
||||
if ($password) {
|
||||
$sshpassExe = Get-CommandPath "sshpass"
|
||||
function Get-DownloadArtifactFiles([string]$rootPath) {
|
||||
$downloadsRoot = [System.IO.Path]::GetFullPath((Join-Path $rootPath "downloads"))
|
||||
$downloadsPrefix = $downloadsRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
|
||||
$manifestFiles = @(Get-ChildItem -LiteralPath $downloadsRoot -Recurse -Filter "manifest.json" -File)
|
||||
$artifactPaths = @()
|
||||
|
||||
foreach ($manifestFile in $manifestFiles) {
|
||||
try {
|
||||
$manifest = [System.IO.File]::ReadAllText(
|
||||
$manifestFile.FullName,
|
||||
[System.Text.Encoding]::UTF8
|
||||
) | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Invalid download manifest JSON: $($manifestFile.FullName)"
|
||||
}
|
||||
|
||||
$sourceType = [string]$manifest.source_type
|
||||
if ($sourceType -notin @("server_release", "company_archive")) {
|
||||
continue
|
||||
}
|
||||
|
||||
$relativePath = [string]$manifest.relative_path
|
||||
if (-not $relativePath) {
|
||||
$relativePath = [string]$manifest.relativePath
|
||||
}
|
||||
$relativePath = $relativePath.Replace("\", "/")
|
||||
|
||||
if (-not $relativePath -or -not $relativePath.StartsWith("downloads/", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Download manifest must reference a path under downloads/: $($manifestFile.FullName)"
|
||||
}
|
||||
|
||||
$segments = @($relativePath -split "/")
|
||||
if ($segments -contains ".." -or $segments -contains ".") {
|
||||
throw "Download manifest contains an unsafe relative path: $relativePath"
|
||||
}
|
||||
|
||||
$artifactFullPath = [System.IO.Path]::GetFullPath(
|
||||
(Join-Path $rootPath ($relativePath.Replace("/", [System.IO.Path]::DirectorySeparatorChar)))
|
||||
)
|
||||
if (-not $artifactFullPath.StartsWith($downloadsPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Download artifact escapes the downloads directory: $relativePath"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $artifactFullPath -PathType Leaf)) {
|
||||
throw "Download artifact referenced by manifest is missing: $relativePath"
|
||||
}
|
||||
|
||||
$artifactPaths += $relativePath
|
||||
}
|
||||
|
||||
return @($artifactPaths | Sort-Object -Unique)
|
||||
}
|
||||
|
||||
if (-not $HostName -and $env:DEPLOY_HOST) {
|
||||
$HostName = $env:DEPLOY_HOST
|
||||
}
|
||||
|
||||
if ($User -notmatch '^[A-Za-z0-9._-]+$') {
|
||||
throw "Remote user contains unsupported characters."
|
||||
}
|
||||
|
||||
if ($Port -lt 1 -or $Port -gt 65535) {
|
||||
throw "Remote port must be between 1 and 65535."
|
||||
}
|
||||
|
||||
if ($RemoteDir -cne $expectedRemoteDir) {
|
||||
throw "RemoteDir is locked to $expectedRemoteDir for fixed-asset deployments."
|
||||
}
|
||||
|
||||
if ($RemoteArchive -cne $expectedRemoteArchive) {
|
||||
throw "RemoteArchive is locked to $expectedRemoteArchive."
|
||||
}
|
||||
|
||||
Set-Location $projectRoot
|
||||
|
||||
$files = git ls-files --cached --others --exclude-standard
|
||||
if (-not $files) {
|
||||
throw "No files to publish."
|
||||
$worktreeStatus = @(& git status --porcelain=v1 --untracked-files=all)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to read Git worktree status."
|
||||
}
|
||||
|
||||
$files | Set-Content -Path $fileListPath -Encoding utf8
|
||||
|
||||
if (Test-Path $archivePath) {
|
||||
Remove-Item $archivePath -Force
|
||||
if ($worktreeStatus.Count -gt 0 -and -not $AllowDirty) {
|
||||
throw "The worktree is dirty. Commit or clean it before publishing, or use -AllowDirty only for an explicit emergency release."
|
||||
}
|
||||
|
||||
Write-Host ">>> Packing repository files..."
|
||||
Invoke-External -FilePath $tarExe -Arguments @("-czf", $archivePath, "-T", $fileListPath)
|
||||
if ($worktreeStatus.Count -gt 0) {
|
||||
Write-Warning "Dirty worktree explicitly allowed. Only allowlisted release files will be packaged."
|
||||
}
|
||||
|
||||
$gitFileArgs = @("-c", "core.quotepath=false", "ls-files", "--cached", "--others", "--exclude-standard", "--") + $releasePaths
|
||||
$files = @(& git @gitFileArgs | Sort-Object -Unique)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to build the release allowlist."
|
||||
}
|
||||
|
||||
if ($files.Count -eq 0) {
|
||||
throw "No files matched the release allowlist."
|
||||
}
|
||||
|
||||
$artifactFiles = @(Get-DownloadArtifactFiles $projectRoot)
|
||||
$files = @(($files + $artifactFiles) | Sort-Object -Unique)
|
||||
|
||||
$missingFiles = @($files | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) })
|
||||
if ($missingFiles.Count -gt 0) {
|
||||
throw "Release list contains missing or non-file paths: $($missingFiles -join ', ')"
|
||||
}
|
||||
|
||||
$totalBytes = ($files | ForEach-Object { (Get-Item -LiteralPath $_).Length } | Measure-Object -Sum).Sum
|
||||
if ($null -eq $totalBytes) {
|
||||
$totalBytes = 0
|
||||
}
|
||||
$commitId = (& git rev-parse --short HEAD).Trim()
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to read the current Git commit."
|
||||
}
|
||||
|
||||
Write-Host ">>> Release plan"
|
||||
Write-Host " Git commit: $commitId"
|
||||
Write-Host " File count: $($files.Count)"
|
||||
Write-Host " Source size: $([math]::Round($totalBytes / 1MB, 2)) MiB"
|
||||
Write-Host " Download artifacts: $($artifactFiles.Count)"
|
||||
Write-Host " Allowlist: $($releasePaths -join ', ')"
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host ">>> DryRun complete: no archive created and no server connection attempted."
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $HostName) {
|
||||
throw "Provide a server through -HostName or DEPLOY_HOST."
|
||||
}
|
||||
|
||||
if ($HostName -notmatch '^[A-Za-z0-9.:-]+$') {
|
||||
throw "Host name contains unsupported characters."
|
||||
}
|
||||
|
||||
$tarExe = Get-CommandPath "tar"
|
||||
$remoteTarget = "$User@$HostName"
|
||||
$sshBaseArgs = @("-o", "StrictHostKeyChecking=no")
|
||||
$scpBaseArgs = @("-o", "StrictHostKeyChecking=no")
|
||||
|
||||
if ($password) {
|
||||
Write-Host ">>> Using DEPLOY_PASSWORD for upload and remote execution..."
|
||||
$scpArgs = @("-p", $password, $scpExe) + $scpBaseArgs + @($archivePath, "${remoteTarget}:$RemoteArchive")
|
||||
Invoke-External -FilePath $sshpassExe -Arguments $scpArgs
|
||||
} else {
|
||||
Write-Host ">>> Using SSH key or interactive password..."
|
||||
$scpArgs = $scpBaseArgs + @($archivePath, "${remoteTarget}:$RemoteArchive")
|
||||
Invoke-External -FilePath $scpExe -Arguments $scpArgs
|
||||
}
|
||||
|
||||
$remoteDirLiteral = ConvertTo-ShellLiteral $RemoteDir
|
||||
$remoteArchiveLiteral = ConvertTo-ShellLiteral $RemoteArchive
|
||||
$skipDeployValue = if ($SkipDeploy) { "1" } else { "0" }
|
||||
$skipBackupValue = if ($SkipBackup) { "1" } else { "0" }
|
||||
$deployCommand = @"
|
||||
set -e
|
||||
mkdir -p '$RemoteDir'
|
||||
cd '$RemoteDir'
|
||||
rm -rf backend frontend scripts AGENTS.md BACKUP.md README.md deploy.sh docker-compose.yml Dockerfile
|
||||
tar -xzf '$RemoteArchive' -C '$RemoteDir'
|
||||
sed -i 's/\r$//' '$RemoteDir/deploy.sh' '$RemoteDir/scripts/backup-equipment.sh'
|
||||
chmod +x '$RemoteDir/deploy.sh' '$RemoteDir/scripts/backup-equipment.sh'
|
||||
if [ '$($SkipDeploy.IsPresent)' = 'True' ]; then
|
||||
echo '>>> 已设置 SkipDeploy,仅同步文件,不执行部署'
|
||||
else
|
||||
if [ '$skipBackupValue' = '1' ]; then
|
||||
SKIP_BACKUP=1 '$RemoteDir/deploy.sh'
|
||||
else
|
||||
'$RemoteDir/deploy.sh'
|
||||
fi
|
||||
fi
|
||||
rm -f '$RemoteArchive'
|
||||
"@
|
||||
|
||||
if ($password) {
|
||||
$sshArgs = @("-p", $password, $sshExe) + $sshBaseArgs + @($remoteTarget, $deployCommand)
|
||||
Invoke-External -FilePath $sshpassExe -Arguments $sshArgs
|
||||
} else {
|
||||
$sshArgs = $sshBaseArgs + @($remoteTarget, $deployCommand)
|
||||
Invoke-External -FilePath $sshExe -Arguments $sshArgs
|
||||
$deployCommandTemplate = @'
|
||||
set -euo pipefail
|
||||
REMOTE_DIR=__REMOTE_DIR__
|
||||
REMOTE_ARCHIVE=__REMOTE_ARCHIVE__
|
||||
SKIP_DEPLOY=__SKIP_DEPLOY__
|
||||
SKIP_BACKUP=__SKIP_BACKUP__
|
||||
STAGING_DIR="$(mktemp -d /tmp/equipment-management-release.XXXXXX)"
|
||||
BRANDING_BACKUP="$(mktemp -d /tmp/equipment-management-branding.XXXXXX)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$STAGING_DIR" "$BRANDING_BACKUP"
|
||||
rm -f "$REMOTE_ARCHIVE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
Write-Host ">>> Publish completed"
|
||||
echo '>>> Validating and extracting release archive...'
|
||||
tar -tzf "$REMOTE_ARCHIVE" >/dev/null
|
||||
tar -xzf "$REMOTE_ARCHIVE" -C "$STAGING_DIR"
|
||||
|
||||
for required_path in \
|
||||
backend/package.json \
|
||||
frontend/package.json \
|
||||
scripts/backup-equipment.sh \
|
||||
deploy.sh \
|
||||
docker-compose.yml; do
|
||||
if [ ! -f "$STAGING_DIR/$required_path" ]; then
|
||||
echo "Release archive is missing required file: $required_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -d "$REMOTE_DIR/frontend/public/branding" ]; then
|
||||
cp -a "$REMOTE_DIR/frontend/public/branding/." "$BRANDING_BACKUP/"
|
||||
fi
|
||||
|
||||
mkdir -p "$REMOTE_DIR"
|
||||
rm -rf "$REMOTE_DIR/backend" "$REMOTE_DIR/frontend" "$REMOTE_DIR/scripts"
|
||||
rm -f \
|
||||
"$REMOTE_DIR/.env.example" \
|
||||
"$REMOTE_DIR/AGENTS.md" \
|
||||
"$REMOTE_DIR/BACKUP.md" \
|
||||
"$REMOTE_DIR/Dockerfile" \
|
||||
"$REMOTE_DIR/README.md" \
|
||||
"$REMOTE_DIR/deploy.sh" \
|
||||
"$REMOTE_DIR/docker-compose.yml"
|
||||
|
||||
cp -a "$STAGING_DIR/backend" "$REMOTE_DIR/backend"
|
||||
cp -a "$STAGING_DIR/frontend" "$REMOTE_DIR/frontend"
|
||||
cp -a "$STAGING_DIR/scripts" "$REMOTE_DIR/scripts"
|
||||
|
||||
if [ -d "$STAGING_DIR/downloads" ]; then
|
||||
mkdir -p "$REMOTE_DIR/downloads"
|
||||
cp -a "$STAGING_DIR/downloads/." "$REMOTE_DIR/downloads/"
|
||||
fi
|
||||
|
||||
for root_file in \
|
||||
.env.example \
|
||||
AGENTS.md \
|
||||
BACKUP.md \
|
||||
Dockerfile \
|
||||
README.md \
|
||||
deploy.sh \
|
||||
docker-compose.yml; do
|
||||
if [ -f "$STAGING_DIR/$root_file" ]; then
|
||||
cp -a "$STAGING_DIR/$root_file" "$REMOTE_DIR/$root_file"
|
||||
fi
|
||||
done
|
||||
|
||||
if find "$BRANDING_BACKUP" -mindepth 1 -print -quit | grep -q .; then
|
||||
mkdir -p "$REMOTE_DIR/frontend/public/branding"
|
||||
cp -a "$BRANDING_BACKUP/." "$REMOTE_DIR/frontend/public/branding/"
|
||||
fi
|
||||
|
||||
sed -i 's/\r$//' "$REMOTE_DIR/deploy.sh" "$REMOTE_DIR/scripts/backup-equipment.sh"
|
||||
chmod +x "$REMOTE_DIR/deploy.sh" "$REMOTE_DIR/scripts/backup-equipment.sh"
|
||||
|
||||
if [ "$SKIP_DEPLOY" = "1" ]; then
|
||||
echo '>>> SkipDeploy enabled: allowlisted files synced without deployment'
|
||||
elif [ "$SKIP_BACKUP" = "1" ]; then
|
||||
echo '>>> WARNING: pre-deploy backup explicitly skipped'
|
||||
SKIP_BACKUP=1 "$REMOTE_DIR/deploy.sh"
|
||||
else
|
||||
"$REMOTE_DIR/deploy.sh"
|
||||
fi
|
||||
'@
|
||||
|
||||
$deployCommand = $deployCommandTemplate
|
||||
$deployCommand = $deployCommand.Replace("__REMOTE_DIR__", $remoteDirLiteral)
|
||||
$deployCommand = $deployCommand.Replace("__REMOTE_ARCHIVE__", $remoteArchiveLiteral)
|
||||
$deployCommand = $deployCommand.Replace("__SKIP_DEPLOY__", $skipDeployValue)
|
||||
$deployCommand = $deployCommand.Replace("__SKIP_BACKUP__", $skipBackupValue)
|
||||
|
||||
try {
|
||||
[System.IO.File]::WriteAllLines(
|
||||
$fileListPath,
|
||||
[string[]]$files,
|
||||
$utf8NoBom
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $archivePath) {
|
||||
Remove-Item -LiteralPath $archivePath -Force
|
||||
}
|
||||
|
||||
Write-Host ">>> Creating release archive..."
|
||||
Invoke-External -FilePath $tarExe -Arguments @("-czf", $archivePath, "-T", $fileListPath) -Description "Create release archive"
|
||||
|
||||
if ($password) {
|
||||
$plinkExe = Get-CommandPath "plink"
|
||||
$pscpExe = Get-CommandPath "pscp"
|
||||
$puttyBaseArgs = @("-batch", "-P", [string]$Port)
|
||||
if ($hostKey) {
|
||||
$puttyBaseArgs += @("-hostkey", $hostKey)
|
||||
}
|
||||
|
||||
Write-Host ">>> Uploading and executing through PuTTY..."
|
||||
$pscpArgs = $puttyBaseArgs + @("-pw", $password, $archivePath, "${remoteTarget}:$RemoteArchive")
|
||||
Invoke-External -FilePath $pscpExe -Arguments $pscpArgs -Description "Upload release archive"
|
||||
|
||||
$plinkArgs = $puttyBaseArgs + @("-pw", $password, $remoteTarget, $deployCommand)
|
||||
Invoke-External -FilePath $plinkExe -Arguments $plinkArgs -Description "Run remote deployment"
|
||||
} else {
|
||||
$sshExe = Get-CommandPath "ssh"
|
||||
$scpExe = Get-CommandPath "scp"
|
||||
$sshBaseArgs = @("-p", [string]$Port, "-o", "StrictHostKeyChecking=accept-new")
|
||||
$scpBaseArgs = @("-P", [string]$Port, "-o", "StrictHostKeyChecking=accept-new")
|
||||
|
||||
Write-Host ">>> Uploading and executing through OpenSSH..."
|
||||
Invoke-External -FilePath $scpExe -Arguments ($scpBaseArgs + @($archivePath, "${remoteTarget}:$RemoteArchive")) -Description "Upload release archive"
|
||||
Invoke-External -FilePath $sshExe -Arguments ($sshBaseArgs + @($remoteTarget, $deployCommand)) -Description "Run remote deployment"
|
||||
}
|
||||
|
||||
Write-Host ">>> Publish workflow completed"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $fileListPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $archivePath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user