fix(发布): 收紧备份与部署安全边界

This commit is contained in:
WuYongJie 2026-07-10 11:28:03 +08:00
parent bc7d3cca17
commit 7cdbe3a507
7 changed files with 459 additions and 101 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -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`
三种方式都无法取得密码时,备份会明确失败,不会继续生成不完整备份,也不会把密码打印到日志。
## 备份内容
每次执行会生成一个时间戳目录,包含:

View File

@ -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
View File

@ -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 "=========================================="

View File

@ -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

View File

@ -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}"

View File

@ -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
}