391 lines
14 KiB
PowerShell
391 lines
14 KiB
PowerShell
#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"
|
||
cp -a docker-compose.yml "$backup_root/docker-compose-before-git-release-$stamp.yml"
|
||
chmod 600 "$backup_root/docker-compose-before-git-release-$stamp.yml"
|
||
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
|
||
|
||
|
||
compose_path = Path('docker-compose.yml')
|
||
compose_text = compose_path.read_text(encoding='utf-8')
|
||
compose_marker = ' YYD_NOTIFICATION_PROJECTS_JSON: ${YYD_NOTIFICATION_PROJECTS_JSON}'
|
||
compose_required = [
|
||
' YYD_NOTIFICATION_DIRECTORY_URL: ${YYD_NOTIFICATION_DIRECTORY_URL:-https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve}',
|
||
' YYD_NOTIFICATION_ADMIN_API_SECRET: ${YYD_NOTIFICATION_ADMIN_API_SECRET}',
|
||
' YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY: ${YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY}',
|
||
]
|
||
present = [line in compose_text for line in compose_required]
|
||
if any(present) and not all(present):
|
||
raise SystemExit('通知中心 Compose 管理环境映射不完整,拒绝猜测性修改')
|
||
if not all(present):
|
||
if compose_text.count(compose_marker) != 1:
|
||
raise SystemExit('通知中心 Compose 项目配置锚点不唯一,拒绝修改')
|
||
compose_text = compose_text.replace(
|
||
compose_marker,
|
||
compose_marker + '\n' + '\n'.join(compose_required),
|
||
)
|
||
compose_path.write_text(compose_text, encoding='utf-8')
|
||
|
||
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)
|
||
)
|
||
directory_url = (
|
||
existing_env.get('YYD_NOTIFICATION_DIRECTORY_URL')
|
||
or 'https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve'
|
||
)
|
||
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_DIRECTORY_URL': directory_url,
|
||
'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
|
||
}
|