380 lines
12 KiB
PowerShell
380 lines
12 KiB
PowerShell
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]$AllowDirty,
|
|
[switch]$IncludeDownloadArtifacts,
|
|
[switch]$DryRun
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
$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",
|
|
"scripts/backup-equipment.sh",
|
|
".env.example",
|
|
"AGENTS.md",
|
|
"BACKUP.md",
|
|
"Dockerfile",
|
|
"README.md",
|
|
"deploy.sh",
|
|
"docker-compose.yml"
|
|
)
|
|
|
|
if ($IncludeDownloadArtifacts) {
|
|
$releasePaths += "downloads"
|
|
}
|
|
|
|
function Get-CommandPath([string]$name) {
|
|
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
|
if (-not $cmd) {
|
|
throw "Command not found: $name"
|
|
}
|
|
return $cmd.Source
|
|
}
|
|
|
|
function Get-TarCommandPath {
|
|
$gitCommand = Get-Command "git" -ErrorAction SilentlyContinue
|
|
if ($gitCommand) {
|
|
$gitRoot = Split-Path (Split-Path $gitCommand.Source -Parent) -Parent
|
|
$gitTar = Join-Path $gitRoot "usr\bin\tar.exe"
|
|
if (Test-Path -LiteralPath $gitTar -PathType Leaf) {
|
|
return $gitTar
|
|
}
|
|
}
|
|
|
|
return Get-CommandPath "tar"
|
|
}
|
|
|
|
function Invoke-External {
|
|
param(
|
|
[string]$FilePath,
|
|
[string[]]$Arguments,
|
|
[string]$Description = "External command"
|
|
)
|
|
|
|
& $FilePath @Arguments
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "$Description failed with exit code $LASTEXITCODE"
|
|
}
|
|
}
|
|
|
|
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'"
|
|
}
|
|
|
|
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
|
|
|
|
$worktreeStatus = @(& git status --porcelain=v1 --untracked-files=all)
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Failed to read Git worktree status."
|
|
}
|
|
|
|
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 ($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 = if ($IncludeDownloadArtifacts) {
|
|
@(Get-DownloadArtifactFiles $projectRoot)
|
|
} else {
|
|
@()
|
|
}
|
|
$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 " Include download artifacts: $([bool]$IncludeDownloadArtifacts)"
|
|
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-TarCommandPath
|
|
$tarDirectory = Split-Path $tarExe -Parent
|
|
$useGnuTar = Test-Path -LiteralPath (Join-Path $tarDirectory "gzip.exe") -PathType Leaf
|
|
$remoteTarget = "$User@$HostName"
|
|
$remoteDirLiteral = ConvertTo-ShellLiteral $RemoteDir
|
|
$remoteArchiveLiteral = ConvertTo-ShellLiteral $RemoteArchive
|
|
$skipDeployValue = if ($SkipDeploy) { "1" } else { "0" }
|
|
$skipBackupValue = if ($SkipBackup) { "1" } else { "0" }
|
|
|
|
$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
|
|
|
|
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)
|
|
$deployCommand = $deployCommand.Replace("`r`n", "`n")
|
|
|
|
try {
|
|
[System.IO.File]::WriteAllText(
|
|
$fileListPath,
|
|
(($files -join "`n") + "`n"),
|
|
$utf8NoBom
|
|
)
|
|
|
|
if (Test-Path -LiteralPath $archivePath) {
|
|
Remove-Item -LiteralPath $archivePath -Force
|
|
}
|
|
|
|
Write-Host ">>> Creating release archive..."
|
|
$tarArguments = @("-czf", $archivePath, "-T", $fileListPath)
|
|
$originalPath = $env:PATH
|
|
if ($useGnuTar) {
|
|
$env:PATH = "$tarDirectory;$env:PATH"
|
|
$tarArguments = @("--force-local") + $tarArguments
|
|
}
|
|
try {
|
|
Invoke-External -FilePath $tarExe -Arguments $tarArguments -Description "Create release archive"
|
|
} finally {
|
|
$env:PATH = $originalPath
|
|
}
|
|
|
|
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
|
|
}
|