210 lines
7.7 KiB
PowerShell
210 lines
7.7 KiB
PowerShell
#Requires -Version 5.1
|
|
<#
|
|
One-command deploy for yelaymaths_api / yelaymaths_admin.
|
|
|
|
Packs source files locally, uploads over SSH, builds on the server and restarts the process.
|
|
No service is installed on the server; it reuses the SSH access you already have.
|
|
|
|
Usage:
|
|
.\deploy\deploy.ps1 api
|
|
.\deploy\deploy.ps1 admin
|
|
.\deploy\deploy.ps1 all
|
|
.\deploy\deploy.ps1 api -DryRun # pack only, show what would be uploaded
|
|
.\deploy\deploy.ps1 api -WithInstall # also install dependencies (only after package.json changes)
|
|
|
|
Files are packed from an explicit whitelist, so .env / node_modules / dist / .next
|
|
are never uploaded and your server-side .env stays untouched.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[ValidateSet('api', 'admin', 'all')]
|
|
[string]$Target = 'all',
|
|
[switch]$DryRun,
|
|
[switch]$WithInstall
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
|
|
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
$repoRoot = Split-Path -Parent $scriptDir
|
|
$configPath = Join-Path $scriptDir 'deploy.config.ps1'
|
|
|
|
if (-not (Test-Path $configPath)) {
|
|
Write-Host 'ERROR: deploy.config.ps1 not found.' -ForegroundColor Red
|
|
Write-Host 'Copy deploy.config.example.ps1 to deploy.config.ps1 and fill in your server details.'
|
|
exit 1
|
|
}
|
|
. $configPath
|
|
|
|
foreach ($exe in @('ssh', 'scp', 'tar')) {
|
|
if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) {
|
|
throw "Missing required command: $exe (Windows 10+ ships OpenSSH and tar)"
|
|
}
|
|
}
|
|
|
|
$sshTarget = '{0}@{1}' -f $Config.User, $Config.Host
|
|
|
|
function Invoke-AppDeploy {
|
|
param([hashtable]$App)
|
|
|
|
$local = Join-Path $repoRoot $App.LocalPath
|
|
if (-not (Test-Path $local)) { throw "Local path not found: $local" }
|
|
|
|
# Whitelist: only include paths that exist. Everything else (secrets, build output)
|
|
# is structurally impossible to upload.
|
|
$items = @()
|
|
foreach ($p in $App.DeployPaths) {
|
|
if (Test-Path (Join-Path $local $p)) { $items += $p }
|
|
}
|
|
if ($items.Count -eq 0) { throw "Nothing to deploy for $($App.Key)" }
|
|
|
|
$excludes = @()
|
|
foreach ($p in ($App.ExcludePaths ?? @())) { $excludes += "--exclude=$p" }
|
|
|
|
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
$tarName = 'yelay-{0}-{1}.tar.gz' -f $App.Key, $stamp
|
|
$tarFile = Join-Path $env:TEMP $tarName
|
|
$remoteTar = '/tmp/' + $tarName
|
|
|
|
Write-Host ''
|
|
Write-Host ('=== {0} ===' -f $App.Label) -ForegroundColor Cyan
|
|
|
|
# ---- 本地构建:把耗时的编译放在本机,服务器只接收产物 ----
|
|
if ($App.LocalBuildCmd) {
|
|
Write-Host ('[1/5] Building locally: {0}' -f $App.LocalBuildCmd)
|
|
# 国内拉取 Prisma 引擎很慢,指向镜像
|
|
$env:PRISMA_ENGINES_MIRROR = 'https://registry.npmmirror.com/-/binary/prisma'
|
|
Push-Location $local
|
|
try {
|
|
Invoke-Expression $App.LocalBuildCmd
|
|
if ($LASTEXITCODE -ne 0) { throw "Local build failed: $($App.LocalBuildCmd)" }
|
|
} finally {
|
|
Pop-Location
|
|
}
|
|
} else {
|
|
Write-Host '[1/5] Skipping local build'
|
|
}
|
|
|
|
Write-Host ('[2/5] Packing {0}' -f $App.LocalPath)
|
|
Push-Location $local
|
|
try {
|
|
& tar -czf $tarFile @excludes @items
|
|
if ($LASTEXITCODE -ne 0) { throw 'tar failed' }
|
|
} finally {
|
|
Pop-Location
|
|
}
|
|
$sizeKb = [math]::Round((Get-Item $tarFile).Length / 1KB, 1)
|
|
Write-Host (' {0} KB ({1})' -f $sizeKb, ($items -join ', '))
|
|
|
|
if ($DryRun) {
|
|
Write-Host ' DryRun: nothing uploaded.'
|
|
Remove-Item $tarFile -Force
|
|
return
|
|
}
|
|
|
|
Write-Host ('[3/5] Uploading to {0}' -f $App.RemotePath)
|
|
& scp -P $Config.Port -q $tarFile ('{0}:{1}' -f $sshTarget, $remoteTar)
|
|
if ($LASTEXITCODE -ne 0) { throw 'scp failed' }
|
|
|
|
$install = ''
|
|
if ($WithInstall) { $install = $App.InstallCmd }
|
|
|
|
# 部署前先在服务器上删掉指定的旧目录。
|
|
#
|
|
# 为什么必须要这一步:Next.js 会把预渲染好的页面缓存在 .next/cache 里,
|
|
# 命中时直接返回缓存(响应头 x-nextjs-cache: HIT),不再读磁盘上的新文件。
|
|
# 而 .next/cache 体积很大(约 73MB),上传时会排除掉,于是它永远是旧的 ——
|
|
# 表现就是「代码传上去了、也重启了,页面却一直没变」。这里先删掉,让它重建。
|
|
$cleanCmd = ''
|
|
foreach ($p in ($App.CleanPaths ?? @())) {
|
|
$cleanCmd += "rm -rf '$($App.RemotePath)/$p'`n"
|
|
}
|
|
|
|
# 部署后清空 Nginx 反代缓存。
|
|
#
|
|
# 为什么必须清:宝塔在 /www/server/nginx/conf/proxy.conf 里全局开了
|
|
# proxy_cache cache_one,而 Next.js 给静态页面发的响应头是
|
|
# Cache-Control: s-maxage=31536000,等于告诉 Nginx「这一页可以存一年」。
|
|
# 于是新构建传上去、进程也重启了,访问到的还是缓存的旧 HTML;旧 HTML
|
|
# 引用的分包早被新构建删掉,一请求就是 404,页面渲染到一半就断,
|
|
# 表现就是「怎么改都不生效」,甚至某个区块整块消失。
|
|
# 清掉缓存目录再 reload,让它回源读新构建。
|
|
$nginxCacheCmd = ''
|
|
if ($Config.NginxCacheDir) {
|
|
$nginxCacheCmd += "find '$($Config.NginxCacheDir)' -mindepth 1 -delete 2>/dev/null || true`n"
|
|
if ($Config.NginxBin) {
|
|
$nginxCacheCmd += "'$($Config.NginxBin)' -s reload 2>/dev/null || true`n"
|
|
}
|
|
}
|
|
|
|
# 启动命令。
|
|
#
|
|
# RestartCmd 只负责杀掉旧进程,宝塔不会自动把新进程拉起来,
|
|
# 所以这里自己拉。先 sleep 2 是为了让刚被杀掉的进程彻底放开端口,
|
|
# 否则新进程会 EADDRINUSE 起不来。
|
|
$startCmd = ''
|
|
if ($App.StartCmd) {
|
|
$startCmd = "sleep 2`n$($App.StartCmd)`n"
|
|
}
|
|
|
|
# 启动后探活:确认端口真的在响应,而不是「命令敲下去就当成功」。
|
|
# 30 秒还没起来就让部署直接失败,免得又出现「显示部署成功、线上却是 502」。
|
|
$healthCmd = ''
|
|
if ($App.HealthUrl) {
|
|
$healthCmd = "for i in {1..30}; do curl -sf -o /dev/null '$($App.HealthUrl)' && break; sleep 1; done`n"
|
|
$healthCmd += "curl -sf -o /dev/null '$($App.HealthUrl)' || { echo 'ERROR: service did not come up: $($App.HealthUrl)'; exit 1; }`n"
|
|
}
|
|
|
|
# Remote script is intentionally ASCII-only to avoid encoding issues over SSH.
|
|
$remoteScript = @"
|
|
set -e
|
|
export PATH=$($Config.NodeBin):`$PATH
|
|
mkdir -p '$($App.RemotePath)'
|
|
cd '$($App.RemotePath)'
|
|
$cleanCmd
|
|
tar -xzf '$remoteTar'
|
|
rm -f '$remoteTar'
|
|
$install
|
|
$($App.BuildCmd)
|
|
$($App.RestartCmd)
|
|
$startCmd
|
|
$healthCmd
|
|
$nginxCacheCmd
|
|
echo '__DEPLOY_DONE__'
|
|
"@
|
|
|
|
Write-Host '[4/5] Running on server'
|
|
$remoteScript | & ssh -p $Config.Port $sshTarget 'bash -s'
|
|
if ($LASTEXITCODE -ne 0) { throw 'remote build or restart failed' }
|
|
|
|
Remove-Item $tarFile -Force -ErrorAction SilentlyContinue
|
|
|
|
if ($App.RestartCmd) {
|
|
Write-Host '[5/5] Restarted' -ForegroundColor Green
|
|
} else {
|
|
Write-Host '[5/5] Done' -ForegroundColor Green
|
|
Write-Host (' NOTE: restart "{0}" from the BT panel now.' -f $App.Label) -ForegroundColor Yellow
|
|
}
|
|
if ($App.StartCmd) {
|
|
Write-Host (' Started, health check passed: {0}' -f $App.HealthUrl) -ForegroundColor Green
|
|
}
|
|
if ($nginxCacheCmd) {
|
|
Write-Host ' Nginx proxy cache cleared and reloaded' -ForegroundColor Green
|
|
}
|
|
}
|
|
|
|
$apps = switch ($Target) {
|
|
'api' { @($Config.Api) }
|
|
'admin' { @($Config.Admin) }
|
|
'all' { @($Config.Api, $Config.Admin) }
|
|
}
|
|
|
|
$started = Get-Date
|
|
foreach ($app in $apps) { Invoke-AppDeploy -App $app }
|
|
|
|
if (-not $DryRun) {
|
|
Write-Host ''
|
|
Write-Host ('All done in {0:N1}s' -f ((Get-Date) - $started).TotalSeconds) -ForegroundColor Green
|
|
}
|