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
|
04-borrow-management.png
|
||||||
05-settings-page.png
|
05-settings-page.png
|
||||||
nul
|
nul
|
||||||
{
|
|
||||||
.dotnet8/
|
.dotnet8/
|
||||||
print-agent-dotnet/**/bin/
|
print-agent-dotnet/**/bin/
|
||||||
print-agent-dotnet/**/obj/
|
print-agent-dotnet/**/obj/
|
||||||
@ -39,3 +38,6 @@ temp_db_purchase_dates.tsv
|
|||||||
codex_release_files.txt
|
codex_release_files.txt
|
||||||
dingtalk-requests.txt
|
dingtalk-requests.txt
|
||||||
行政手动处理清单*.xlsx
|
行政手动处理清单*.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
|
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`
|
- 上传到 `/opt/equipment-management`
|
||||||
- 只覆盖固定资产系统源码
|
- 校验发布包并保留服务器上的自定义品牌文件
|
||||||
|
- 只覆盖固定资产系统源码,不处理 `shebeiguanli-*`
|
||||||
- 调用服务器上的安全部署脚本
|
- 调用服务器上的安全部署脚本
|
||||||
|
|
||||||
常用参数:
|
常用参数:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
.\scripts\publish-to-server.ps1 -DryRun
|
||||||
.\scripts\publish-to-server.ps1 -SkipDeploy
|
.\scripts\publish-to-server.ps1 -SkipDeploy
|
||||||
.\scripts\publish-to-server.ps1 -SkipBackup
|
.\scripts\publish-to-server.ps1 -SkipBackup
|
||||||
|
.\scripts\publish-to-server.ps1 -DryRun -AllowDirty
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `-DryRun`:只显示发布文件数量、体积和白名单,不创建压缩包、不连接服务器
|
||||||
- `-SkipDeploy`:只同步代码,不执行服务器部署
|
- `-SkipDeploy`:只同步代码,不执行服务器部署
|
||||||
- `-SkipBackup`:执行部署,但跳过备份,不建议常用
|
- `-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`。
|
默认只保留最近 `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"
|
PROJECT_NAME="equipment-management"
|
||||||
BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh"
|
BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh"
|
||||||
SKIP_BACKUP="${SKIP_BACKUP:-0}"
|
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")
|
EQUIPMENT_CONTAINERS=("equipment-frontend" "equipment-backend" "equipment-mysql")
|
||||||
LEGACY_CONTAINERS=("shebeiguanli-frontend" "shebeiguanli-backend" "shebeiguanli-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
|
if docker compose version >/dev/null 2>&1; then
|
||||||
COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE")
|
COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE")
|
||||||
@ -34,8 +112,20 @@ if [ ! -f "$COMPOSE_FILE" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
record_legacy_container_statuses
|
||||||
|
|
||||||
if [ "$SKIP_BACKUP" = "1" ]; then
|
if [ "$SKIP_BACKUP" = "1" ]; then
|
||||||
echo ">>> 已设置 SKIP_BACKUP=1,跳过备份"
|
echo ">>> 已设置 SKIP_BACKUP=1,跳过备份"
|
||||||
else
|
else
|
||||||
@ -48,36 +138,30 @@ else
|
|||||||
"$BACKUP_SCRIPT"
|
"$BACKUP_SCRIPT"
|
||||||
fi
|
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 ">>> 先构建固定资产系统镜像..."
|
echo ">>> 先构建固定资产系统镜像..."
|
||||||
"${COMPOSE_CMD[@]}" build
|
"${COMPOSE_CMD[@]}" build
|
||||||
|
|
||||||
echo ">>> 镜像构建成功,开始更新固定资产系统容器..."
|
echo ">>> 镜像构建成功,开始更新固定资产系统容器..."
|
||||||
"${COMPOSE_CMD[@]}" down
|
"${COMPOSE_CMD[@]}" up -d --remove-orphans
|
||||||
"${COMPOSE_CMD[@]}" up -d
|
|
||||||
|
|
||||||
echo ">>> 等待服务启动..."
|
deployment_failed=0
|
||||||
sleep 10
|
|
||||||
|
|
||||||
echo ">>> 固定资产系统容器状态..."
|
if ! wait_for_backend_health; then
|
||||||
for container in "${EQUIPMENT_CONTAINERS[@]}"; do
|
deployment_failed=1
|
||||||
status=$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || echo "missing")
|
fi
|
||||||
echo " $container => $status"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ">>> 再次确认摄影设备借还系统未受影响..."
|
if ! verify_equipment_containers; then
|
||||||
for container in "${LEGACY_CONTAINERS[@]}"; do
|
deployment_failed=1
|
||||||
status=$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || echo "missing")
|
fi
|
||||||
echo " $container => $status"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ">>> 检查固定资产系统后端健康状态..."
|
if ! verify_legacy_container_statuses; then
|
||||||
curl -fsS http://localhost:3000/api/health || echo "后端服务启动中..."
|
deployment_failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$deployment_failed" -ne 0 ]; then
|
||||||
|
echo "固定资产系统部署校验失败,请根据上述错误排查。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|||||||
@ -17,7 +17,7 @@ services:
|
|||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@ -11,14 +11,11 @@ DB_CONTAINER="${DB_CONTAINER:-equipment-mysql}"
|
|||||||
BACKEND_CONTAINER="${BACKEND_CONTAINER:-equipment-backend}"
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-equipment-backend}"
|
||||||
DB_NAME="${DB_NAME:-equipment_management}"
|
DB_NAME="${DB_NAME:-equipment_management}"
|
||||||
DB_USER="${DB_USER:-root}"
|
DB_USER="${DB_USER:-root}"
|
||||||
DB_PASSWORD="${DB_PASSWORD:-jsjj8888}"
|
DB_PASSWORD="${DB_PASSWORD:-${MYSQL_ROOT_PASSWORD:-}}"
|
||||||
UPLOADS_PATH="${UPLOADS_PATH:-/app/uploads}"
|
UPLOADS_PATH="${UPLOADS_PATH:-/app/uploads}"
|
||||||
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||||||
BACKUP_DIR="${BACKUP_ROOT}/${TIMESTAMP}"
|
BACKUP_DIR="${BACKUP_ROOT}/${TIMESTAMP}"
|
||||||
|
|
||||||
mkdir -p "${BACKUP_DIR}"
|
|
||||||
mkdir -p "${BACKUP_DIR}/db" "${BACKUP_DIR}/uploads" "${BACKUP_DIR}/config"
|
|
||||||
|
|
||||||
cleanup_on_error() {
|
cleanup_on_error() {
|
||||||
echo "备份失败,清理不完整目录: ${BACKUP_DIR}"
|
echo "备份失败,清理不完整目录: ${BACKUP_DIR}"
|
||||||
rm -rf "${BACKUP_DIR}"
|
rm -rf "${BACKUP_DIR}"
|
||||||
@ -55,9 +52,6 @@ prune_old_backups() {
|
|||||||
|
|
||||||
trap cleanup_on_error ERR
|
trap cleanup_on_error ERR
|
||||||
|
|
||||||
echo "备份目录: ${BACKUP_DIR}"
|
|
||||||
echo "保留策略: 最近 ${KEEP_BACKUPS} 份"
|
|
||||||
|
|
||||||
if ! docker inspect "${DB_CONTAINER}" >/dev/null 2>&1; then
|
if ! docker inspect "${DB_CONTAINER}" >/dev/null 2>&1; then
|
||||||
echo "数据库容器不存在: ${DB_CONTAINER}"
|
echo "数据库容器不存在: ${DB_CONTAINER}"
|
||||||
exit 1
|
exit 1
|
||||||
@ -68,9 +62,37 @@ if ! docker inspect "${BACKEND_CONTAINER}" >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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}"
|
echo ">>> 备份数据库 ${DB_NAME}"
|
||||||
docker exec "${DB_CONTAINER}" \
|
docker exec -e "MYSQL_PWD=${DB_PASSWORD}" "${DB_CONTAINER}" \
|
||||||
sh -lc "exec mysqldump -u${DB_USER} -p'${DB_PASSWORD}' --single-transaction --routines --triggers ${DB_NAME}" \
|
mysqldump -u"${DB_USER}" --single-transaction --routines --triggers "${DB_NAME}" \
|
||||||
| gzip > "${BACKUP_DIR}/db/${DB_NAME}.sql.gz"
|
| gzip > "${BACKUP_DIR}/db/${DB_NAME}.sql.gz"
|
||||||
|
|
||||||
echo ">>> 备份上传目录 ${UPLOADS_PATH}"
|
echo ">>> 备份上传目录 ${UPLOADS_PATH}"
|
||||||
|
|||||||
@ -1,27 +1,41 @@
|
|||||||
param(
|
param(
|
||||||
[string]$HostName = "",
|
[string]$HostName = "",
|
||||||
[string]$User = "root",
|
[string]$User = "root",
|
||||||
|
[int]$Port = 22,
|
||||||
[string]$RemoteDir = "/opt/equipment-management",
|
[string]$RemoteDir = "/opt/equipment-management",
|
||||||
[string]$RemoteArchive = "/tmp/equipment-management-release.tar.gz",
|
[string]$RemoteArchive = "/tmp/equipment-management-release.tar.gz",
|
||||||
[switch]$SkipDeploy,
|
[switch]$SkipDeploy,
|
||||||
[switch]$SkipBackup
|
[switch]$SkipBackup,
|
||||||
|
[switch]$AllowDirty,
|
||||||
|
[switch]$DryRun
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
if (-not $HostName -and $env:DEPLOY_HOST) {
|
[Console]::OutputEncoding = $utf8NoBom
|
||||||
$HostName = $env:DEPLOY_HOST
|
$OutputEncoding = $utf8NoBom
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $HostName) {
|
|
||||||
throw "请通过 -HostName 或 DEPLOY_HOST 提供部署服务器地址。"
|
|
||||||
}
|
|
||||||
|
|
||||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
$password = if ($env:DEPLOY_PASSWORD) { $env:DEPLOY_PASSWORD } else { "" }
|
$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"
|
$archiveName = "equipment-management-release.tar.gz"
|
||||||
$archivePath = Join-Path ([System.IO.Path]::GetTempPath()) $archiveName
|
$archivePath = Join-Path ([System.IO.Path]::GetTempPath()) $archiveName
|
||||||
$fileListPath = Join-Path ([System.IO.Path]::GetTempPath()) "equipment-management-files.txt"
|
$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) {
|
function Get-CommandPath([string]$name) {
|
||||||
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||||
@ -35,81 +49,296 @@ function Invoke-External {
|
|||||||
param(
|
param(
|
||||||
[string]$FilePath,
|
[string]$FilePath,
|
||||||
[string[]]$Arguments,
|
[string[]]$Arguments,
|
||||||
[switch]$IgnoreExitCode
|
[string]$Description = "External command"
|
||||||
)
|
)
|
||||||
|
|
||||||
& $FilePath @Arguments
|
& $FilePath @Arguments
|
||||||
if (-not $IgnoreExitCode -and $LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "Command failed: $FilePath $($Arguments -join ' ')"
|
throw "$Description failed with exit code $LASTEXITCODE"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$tarExe = Get-CommandPath "tar"
|
function ConvertTo-ShellLiteral([string]$value) {
|
||||||
$sshExe = Get-CommandPath "ssh"
|
if ($value.Contains("'") -or $value.Contains([char]13) -or $value.Contains([char]10)) {
|
||||||
$scpExe = Get-CommandPath "scp"
|
throw "Remote paths cannot contain single quotes or line breaks."
|
||||||
$sshpassExe = $null
|
}
|
||||||
|
return "'$value'"
|
||||||
|
}
|
||||||
|
|
||||||
if ($password) {
|
function Get-DownloadArtifactFiles([string]$rootPath) {
|
||||||
$sshpassExe = Get-CommandPath "sshpass"
|
$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
|
Set-Location $projectRoot
|
||||||
|
|
||||||
$files = git ls-files --cached --others --exclude-standard
|
$worktreeStatus = @(& git status --porcelain=v1 --untracked-files=all)
|
||||||
if (-not $files) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "No files to publish."
|
throw "Failed to read Git worktree status."
|
||||||
}
|
}
|
||||||
|
|
||||||
$files | Set-Content -Path $fileListPath -Encoding utf8
|
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."
|
||||||
if (Test-Path $archivePath) {
|
|
||||||
Remove-Item $archivePath -Force
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ">>> Packing repository files..."
|
if ($worktreeStatus.Count -gt 0) {
|
||||||
Invoke-External -FilePath $tarExe -Arguments @("-czf", $archivePath, "-T", $fileListPath)
|
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"
|
$remoteTarget = "$User@$HostName"
|
||||||
$sshBaseArgs = @("-o", "StrictHostKeyChecking=no")
|
$remoteDirLiteral = ConvertTo-ShellLiteral $RemoteDir
|
||||||
$scpBaseArgs = @("-o", "StrictHostKeyChecking=no")
|
$remoteArchiveLiteral = ConvertTo-ShellLiteral $RemoteArchive
|
||||||
|
$skipDeployValue = if ($SkipDeploy) { "1" } else { "0" }
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
$skipBackupValue = if ($SkipBackup) { "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) {
|
$deployCommandTemplate = @'
|
||||||
$sshArgs = @("-p", $password, $sshExe) + $sshBaseArgs + @($remoteTarget, $deployCommand)
|
set -euo pipefail
|
||||||
Invoke-External -FilePath $sshpassExe -Arguments $sshArgs
|
REMOTE_DIR=__REMOTE_DIR__
|
||||||
} else {
|
REMOTE_ARCHIVE=__REMOTE_ARCHIVE__
|
||||||
$sshArgs = $sshBaseArgs + @($remoteTarget, $deployCommand)
|
SKIP_DEPLOY=__SKIP_DEPLOY__
|
||||||
Invoke-External -FilePath $sshExe -Arguments $sshArgs
|
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