YouYiDianNotificationCenter/scripts/git-build-deploy.ps1

364 lines
13 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#Requires -Version 5.1
param(
[string]$RepoUrl = 'http://git.1080p.hk/WuYongJie/YouYiDianNotificationCenter.git',
[string]$Branch = 'main',
[string]$Commit = '',
[string]$BuildServer = 'root@192.168.40.206',
[string]$ProductionServer = 'root@192.168.58.20',
[string]$RemoteBuildDir = '/root/youyidian-notification-center-build',
[string]$RegistryHost = '192.168.40.206:5000',
[string]$ImageName = 'youyidian-notification-center',
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
function ConvertTo-ShellSingleQuoted([string]$Value) {
return "'" + $Value.Replace("'", "'\''") + "'"
}
$remoteScript = @'
set -Eeuo pipefail
REPO_URL=__REPO_URL__
BRANCH=__BRANCH__
COMMIT_INPUT=__COMMIT__
BUILD_DIR=__BUILD_DIR__
PRODUCTION_SERVER=__PRODUCTION_SERVER__
REGISTRY_HOST=__REGISTRY_HOST__
IMAGE_NAME=__IMAGE_NAME__
DRY_RUN=__DRY_RUN__
log() { printf '[INFO] %s\n' "$*"; }
ok() { printf '[OK] %s\n' "$*"; }
fail() { printf '[ERR] %s\n' "$*" >&2; exit 1; }
for command in git docker curl ssh base64; do
command -v "$command" >/dev/null 2>&1 || fail "构建机缺少命令: $command"
done
curl -fsS "http://127.0.0.1:${REGISTRY_HOST##*:}/v2/" >/dev/null \
|| fail '构建机内部镜像仓库不可用'
ssh -o BatchMode=yes -o ConnectTimeout=10 "$PRODUCTION_SERVER" 'true' \
|| fail '构建机无法连接生产机'
if [ ! -d "$BUILD_DIR/.git" ]; then
rm -rf "$BUILD_DIR"
git clone "$REPO_URL" "$BUILD_DIR"
fi
cd "$BUILD_DIR"
git remote set-url origin "$REPO_URL"
git fetch --prune origin "$BRANCH"
target="origin/$BRANCH"
if [ -n "$COMMIT_INPUT" ]; then
target="$COMMIT_INPUT"
fi
git rev-parse --verify "${target}^{commit}" >/dev/null
if [ "$DRY_RUN" = 1 ]; then
ok "预检通过,目标提交: $(git rev-parse "${target}^{commit}")"
exit 0
fi
git checkout --detach "$target"
git reset --hard "$target"
git clean -fdx
commit_sha="$(git rev-parse HEAD)"
short_sha="$(git rev-parse --short=12 HEAD)"
image_tag="production-${short_sha}"
local_commit="localhost:${REGISTRY_HOST##*:}/${IMAGE_NAME}:${image_tag}"
local_latest="localhost:${REGISTRY_HOST##*:}/${IMAGE_NAME}:production-latest"
remote_commit="${REGISTRY_HOST}/${IMAGE_NAME}:${image_tag}"
base_image="localhost:${REGISTRY_HOST##*:}/yyd-base/node:22-alpine"
log "构建提交 ${commit_sha}"
docker pull "$base_image" >/dev/null \
|| fail "内部基础镜像不可用: $base_image"
docker pull "$local_latest" >/dev/null 2>&1 || true
build_args=(--build-arg "BASE_IMAGE=$base_image")
if docker image inspect "$local_latest" >/dev/null 2>&1; then
build_args+=(--cache-from "$local_latest")
fi
DOCKER_BUILDKIT=1 docker build "${build_args[@]}" \
-t "$local_commit" \
-t "$local_latest" \
.
docker push "$local_commit"
docker push "$local_latest"
ok "镜像已推送: $remote_commit"
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 "$PRODUCTION_SERVER" \
"docker pull '$remote_commit'"; then
log '生产机无法直接拉取,改用 SSH 传输镜像'
docker save "$local_commit" | ssh -o BatchMode=yes "$PRODUCTION_SERVER" docker load
ssh -o BatchMode=yes "$PRODUCTION_SERVER" \
"docker tag '$local_commit' '$remote_commit'"
fi
production_script="$(cat <<'PRODUCTION'
set -Eeuo pipefail
target=/mnt/PoolOne/apps/youyidian-notification-center
backup_root=/mnt/PoolOne/apps/youyidian-notification-center-backups
cd "$target"
test -f .env
test -f docker-compose.yml
mkdir -p "$backup_root"
stamp="$(date +%Y%m%d%H%M%S)"
cp -a .env "$backup_root/env-before-git-release-$stamp"
chmod 600 "$backup_root/env-before-git-release-$stamp"
material_env=/mnt/PoolOne/apps/youyidian-3d/.env
test -f "$material_env"
cp -a "$material_env" "$backup_root/material-env-before-git-release-$stamp"
chmod 600 "$backup_root/material-env-before-git-release-$stamp"
# 盘点任务通过统一 H5 免登入口打开,必须显式保留 /inventory 白名单;不放宽为全站路径。
python3 - <<'PY'
from pathlib import Path
import json
import secrets
def parse_env_value(raw: str) -> str:
value = raw.strip()
if value.startswith('"') and value.endswith('"'):
return json.loads(value)
if value.startswith("'") and value.endswith("'"):
return value[1:-1]
return value
env_path = Path('.env')
lines = env_path.read_text(encoding='utf-8').splitlines()
def env_values(source_lines):
result = {}
for line in source_lines:
if '=' not in line or line.lstrip().startswith('#'):
continue
key, raw = line.split('=', 1)
result[key.strip()] = parse_env_value(raw)
return result
def upsert_env(source_lines, updates):
output = []
seen = set()
for line in source_lines:
if '=' in line and not line.lstrip().startswith('#'):
key = line.split('=', 1)[0].strip()
if key in updates:
output.append(key + '=' + str(updates[key]))
seen.add(key)
continue
output.append(line)
for key, value in updates.items():
if key not in seen:
output.append(key + '=' + str(value))
return output
existing_env = env_values(lines)
admin_api_secret = (
existing_env.get('YYD_NOTIFICATION_ADMIN_API_SECRET')
or existing_env.get('YYD_NOTIFICATION_ADMIN_TOKEN')
or secrets.token_hex(32)
)
config_encryption_key = (
existing_env.get('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY')
or secrets.token_hex(32)
)
projects_index = next(
(
index
for index, line in enumerate(lines)
if not line.lstrip().startswith('#') and line.startswith('YYD_NOTIFICATION_PROJECTS_JSON=')
),
None,
)
if projects_index is None:
raise SystemExit('通知中心缺少 YYD_NOTIFICATION_PROJECTS_JSON,拒绝放宽或猜测免登配置')
key, raw_value = lines[projects_index].split('=', 1)
projects = json.loads(parse_env_value(raw_value))
if not isinstance(projects, list):
raise SystemExit('通知中心项目配置不是数组,拒绝修改')
fixed_asset = next(
(item for item in projects if isinstance(item, dict) and item.get('code') == 'fixed_asset'),
None,
)
if not isinstance(fixed_asset, dict):
raise SystemExit('通知中心缺少 fixed_asset 项目配置,拒绝修改')
login = fixed_asset.get('login')
if isinstance(login, dict) and login.get('enabled') is True:
prefixes = login.get('allowedRedirectPathPrefixes')
if not isinstance(prefixes, list):
raise SystemExit('fixed_asset 免登白名单格式无效,拒绝修改')
normalized = [str(prefix).strip() for prefix in prefixes if str(prefix).strip()]
if '/inventory' not in normalized:
normalized.append('/inventory')
login['allowedRedirectPathPrefixes'] = normalized
lines[projects_index] = key + '=' + json.dumps(
json.dumps(projects, ensure_ascii=False, separators=(',', ':')),
ensure_ascii=False,
)
env_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
env_path.chmod(0o600)
lines = upsert_env(lines, {
'YYD_NOTIFICATION_ADMIN_API_SECRET': admin_api_secret,
'YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY': config_encryption_key,
})
env_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
env_path.chmod(0o600)
material_env_path = Path('/mnt/PoolOne/apps/youyidian-3d/.env')
material_lines = material_env_path.read_text(encoding='utf-8').splitlines()
material_lines = upsert_env(material_lines, {
'YYD_NOTIFICATION_CENTER_ADMIN_URL': 'https://sc.jiansun.vip/notification-center/admin',
'YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET': admin_api_secret,
'YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS': '8000',
})
material_env_path.write_text('\n'.join(material_lines) + '\n', encoding='utf-8')
material_env_path.chmod(0o600)
print('fixed_asset 免登回跳白名单已核对 /inventory')
PY
if docker container inspect youyidian-notification-center >/dev/null 2>&1; then
was_running="$(docker inspect -f '{{.State.Running}}' youyidian-notification-center)"
if [ "$was_running" = true ]; then
docker pause youyidian-notification-center >/dev/null
cleanup_unpause() {
docker unpause youyidian-notification-center >/dev/null 2>&1 || true
}
trap cleanup_unpause EXIT INT TERM
fi
data_backup="$backup_root/notification-data-before-git-release-$stamp"
mkdir -p "$data_backup"
# 整体复制可同时保存 notifications.db、-journal、-wal 与 -shm。
if ! docker cp youyidian-notification-center:/app/data/. "$data_backup/" || ! test -s "$data_backup/notifications.db"; then
fail_message='通知中心 SQLite 备份失败,拒绝重建生产容器'
if [ "$was_running" = true ]; then
docker unpause youyidian-notification-center >/dev/null || true
trap - EXIT INT TERM
fi
printf '[ERR] %s\n' "$fail_message" >&2
exit 1
fi
if [ "$was_running" = true ]; then
docker unpause youyidian-notification-center >/dev/null
trap - EXIT INT TERM
fi
fi
old_image="$(docker inspect -f '{{.Config.Image}}' youyidian-notification-center 2>/dev/null || true)"
if [ -n "$old_image" ] && docker image inspect "$old_image" >/dev/null 2>&1; then
docker tag "$old_image" youyidian-notification-center:rollback-before-release
fi
docker tag "$REMOTE_IMAGE" "youyidian-notification-center:$IMAGE_TAG"
YYD_NOTIFICATION_IMAGE_TAG="$IMAGE_TAG" docker compose up -d --no-build --force-recreate
healthy=0
for _ in $(seq 1 30); do
if curl -fsS --max-time 3 http://127.0.0.1:3088/health >/tmp/yyd-notification-health.json; then
healthy=1
break
fi
sleep 2
done
if [ "$healthy" != 1 ]; then
docker compose logs --tail 120
if docker image inspect youyidian-notification-center:rollback-before-release >/dev/null 2>&1; then
YYD_NOTIFICATION_IMAGE_TAG=rollback-before-release docker compose up -d --no-build --force-recreate
fi
exit 1
fi
public_healthy=0
for _ in $(seq 1 15); do
if curl -fsS --max-time 5 https://sc.jiansun.vip/notification-center/health >/tmp/yyd-notification-public-health.json; then
public_healthy=1
break
fi
sleep 2
done
if [ "$public_healthy" != 1 ]; then
echo '通知中心公网健康检查持续失败,保留本机健康的新容器以便排查反向代理。' >&2
exit 1
fi
printf '%s' "$COMMIT_SHA" > DEPLOYED_COMMIT
cat /tmp/yyd-notification-public-health.json
printf '\nDEPLOYED_COMMIT='
cat DEPLOYED_COMMIT
printf '\nCONTAINER_IMAGE='
docker inspect -f '{{.Config.Image}}' youyidian-notification-center
printf '\n'
PRODUCTION
)"
production_script_base64="$(printf '%s\n' "$production_script" | base64 | tr -d '\n')"
ssh -o BatchMode=yes "$PRODUCTION_SERVER" \
"printf '%s' '$production_script_base64' | base64 -d | REMOTE_IMAGE='$remote_commit' IMAGE_TAG='$image_tag' COMMIT_SHA='$commit_sha' bash -s"
ok "通知中心已部署到 ${commit_sha}"
'@
$replacements = @{
'__REPO_URL__' = ConvertTo-ShellSingleQuoted $RepoUrl
'__BRANCH__' = ConvertTo-ShellSingleQuoted $Branch
'__COMMIT__' = ConvertTo-ShellSingleQuoted $Commit
'__BUILD_DIR__' = ConvertTo-ShellSingleQuoted $RemoteBuildDir
'__PRODUCTION_SERVER__' = ConvertTo-ShellSingleQuoted $ProductionServer
'__REGISTRY_HOST__' = ConvertTo-ShellSingleQuoted $RegistryHost
'__IMAGE_NAME__' = ConvertTo-ShellSingleQuoted $ImageName
'__DRY_RUN__' = if ($DryRun) { '1' } else { '0' }
}
foreach ($entry in $replacements.GetEnumerator()) {
$remoteScript = $remoteScript.Replace($entry.Key, $entry.Value)
}
$remoteScript = $remoteScript.Replace("`r`n", "`n")
# Freeze production-script bytes locally before crossing PowerShell, Bash, and SSH.
$productionBlockPatternText = '(?ms)^production_script="\$\(cat <<' + [char]39 + 'PRODUCTION' + [char]39 + '\n(?<body>.*?)\nPRODUCTION\n\)"\n\nproduction_script_base64=.*?$'
$productionBlockPattern = [regex]$productionBlockPatternText
$productionBlockMatch = $productionBlockPattern.Match($remoteScript)
if (-not $productionBlockMatch.Success) {
throw 'Cannot extract the production deployment script; release aborted.'
}
$productionScriptBytes = [System.Text.Encoding]::UTF8.GetBytes(
$productionBlockMatch.Groups['body'].Value + "`n"
)
$productionScriptBase64 = [Convert]::ToBase64String($productionScriptBytes)
$productionBlockReplacement = "production_script_base64=$(ConvertTo-ShellSingleQuoted $productionScriptBase64)"
$remoteScript = $remoteScript.Remove(
$productionBlockMatch.Index,
$productionBlockMatch.Length
).Insert($productionBlockMatch.Index, $productionBlockReplacement)
$localTemp = Join-Path ([System.IO.Path]::GetTempPath()) ("yyd-notification-deploy-{0}.sh" -f [guid]::NewGuid().ToString('N'))
$remoteTemp = "/tmp/$(Split-Path $localTemp -Leaf)"
try {
[System.IO.File]::WriteAllText(
$localTemp,
$remoteScript,
[System.Text.UTF8Encoding]::new($false)
)
& scp -o BatchMode=yes -o ConnectTimeout=10 $localTemp "${BuildServer}:$remoteTemp"
if ($LASTEXITCODE -ne 0) {
throw "通知中心发布脚本上传失败,退出码: $LASTEXITCODE"
}
& ssh -o BatchMode=yes -o ConnectTimeout=10 $BuildServer "chmod 700 '$remoteTemp' && bash '$remoteTemp'; code=`$?; rm -f '$remoteTemp'; exit `$code"
if ($LASTEXITCODE -ne 0) {
throw "通知中心构建机发布失败,退出码: $LASTEXITCODE"
}
}
finally {
Remove-Item -LiteralPath $localTemp -Force -ErrorAction SilentlyContinue
}