From 7cdbe3a507bee94191cedd300083eb8d008851b4 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 10 Jul 2026 11:28:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=8F=91=E5=B8=83):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=E4=B8=8E=E9=83=A8=E7=BD=B2=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- BACKUP.md | 8 + README.md | 17 +- deploy.sh | 128 +++++++++--- docker-compose.yml | 2 +- scripts/backup-equipment.sh | 40 +++- scripts/publish-to-server.ps1 | 361 +++++++++++++++++++++++++++------- 7 files changed, 459 insertions(+), 101 deletions(-) diff --git a/.gitignore b/.gitignore index 30be35f..81c5910 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/BACKUP.md b/BACKUP.md index 2ff969c..1e9c2e4 100644 --- a/BACKUP.md +++ b/BACKUP.md @@ -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` + +三种方式都无法取得密码时,备份会明确失败,不会继续生成不完整备份,也不会把密码打印到日志。 + ## 备份内容 每次执行会生成一个时间戳目录,包含: diff --git a/README.md b/README.md index b1b2ec1..08f2bd1 100644 --- a/README.md +++ b/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`;仍无法取得时会直接终止备份,并且不会输出密码内容。 + ## 人员与组织同步 后端已预留钉钉中台同步能力,支持同步: diff --git a/deploy.sh b/deploy.sh index 99aaa1f..8988d0c 100644 --- a/deploy.sh +++ b/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 "==========================================" diff --git a/docker-compose.yml b/docker-compose.yml index 412220d..bdf4940 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/scripts/backup-equipment.sh b/scripts/backup-equipment.sh index 14f06a5..c03e769 100644 --- a/scripts/backup-equipment.sh +++ b/scripts/backup-equipment.sh @@ -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}" diff --git a/scripts/publish-to-server.ps1 b/scripts/publish-to-server.ps1 index 0c2c63e..a1cfb36 100644 --- a/scripts/publish-to-server.ps1 +++ b/scripts/publish-to-server.ps1 @@ -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 +}