feat(deploy): add Ubuntu 26.04 production package

This commit is contained in:
2026-08-15 22:50:07 +08:00
parent 0a91cafc4e
commit 65eac893d8
28 changed files with 1101 additions and 4 deletions
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=lib.sh
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
env_file="${1:-/etc/lehuo/lehuo.env}"
require_command docker
require_command gzip
require_command sha256sum
require_env_file "$env_file"
backup_dir="$(env_value "$env_file" BACKUP_DIR)"
[[ "$backup_dir" == /opt/lehuo/backups || "$backup_dir" == /opt/lehuo/backups/* ]] || {
echo 'BACKUP_DIR 必须位于 /opt/lehuo/backups。' >&2
exit 1
}
install -d -m 0700 "$backup_dir"
database="$(env_value "$env_file" MYSQL_DATABASE)"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
target="$backup_dir/lehuo-${timestamp}.sql.gz"
temporary="${target}.tmp"
trap 'rm -f -- "$temporary"' EXIT
compose "$env_file" exec -T mysql sh -c \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump --single-transaction --routines --triggers --events --set-gtid-purged=OFF -u root "$MYSQL_DATABASE"' \
| gzip -9 > "$temporary"
gzip -t "$temporary"
mv -- "$temporary" "$target"
chmod 600 "$target"
sha256sum "$target" > "${target}.sha256"
chmod 600 "${target}.sha256"
find "$backup_dir" -maxdepth 1 -type f \( -name 'lehuo-*.sql.gz' -o -name 'lehuo-*.sql.gz.sha256' \) -mtime +30 -delete
printf '数据库备份完成:%s(数据库:%s)\n' "$target" "$database"
echo '请将备份及 SHA-256 文件同步到异地加密存储。'
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=lib.sh
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
env_file="${1:-/etc/lehuo/lehuo.env}"
require_env_file "$env_file"
read -r -p '初始管理员用户名:' username
read -r -p '初始管理员显示名:' display_name
read -r -s -p '初始管理员密码:' password
echo
[[ -n "$username" && -n "$display_name" && ${#password} -ge 12 ]] || {
echo '用户名/显示名不能为空,密码至少 12 位。' >&2
exit 1
}
compose "$env_file" run --rm \
-e "BOOTSTRAP_ADMIN_USERNAME=${username}" \
-e "BOOTSTRAP_ADMIN_DISPLAY_NAME=${display_name}" \
-e "BOOTSTRAP_ADMIN_PASSWORD=${password}" \
api node apps/api/dist/scripts/bootstrap-admin.js
unset password
echo '初始管理员创建完成;命令会拒绝重复初始化。'
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=lib.sh
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
env_file="${1:-/etc/lehuo/lehuo.env}"
require_env_file "$env_file"
bash "$(dirname -- "${BASH_SOURCE[0]}")/preflight.sh" "$env_file"
image_tag="$(env_value "$env_file" IMAGE_TAG)"
release_dir='/opt/lehuo/releases'
install -d -m 0700 "$release_dir"
current_tag=''
if [[ -f "$release_dir/current" ]]; then
current_tag="$(<"$release_dir/current")"
fi
echo "构建不可变镜像:${image_tag}"
compose "$env_file" build --pull api web
echo '启动数据服务并初始化私有对象存储。'
compose "$env_file" up -d mysql redis minio minio-init
if [[ -n "$current_tag" ]]; then
echo '迁移前创建生产数据库备份。'
bash "$(dirname -- "${BASH_SOURCE[0]}")/backup.sh" "$env_file"
fi
echo '执行数据库 migration。'
compose "$env_file" --profile tools run --rm migrate
echo '部署 API 与管理后台网关。'
compose "$env_file" up -d --remove-orphans api web
bash "$(dirname -- "${BASH_SOURCE[0]}")/smoke.sh" "$env_file"
if [[ -n "$current_tag" && "$current_tag" != "$image_tag" ]]; then
printf '%s\n' "$current_tag" > "$release_dir/previous"
fi
printf '%s\n' "$image_tag" > "$release_dir/current"
chmod 600 "$release_dir/current"
[[ ! -f "$release_dir/previous" ]] || chmod 600 "$release_dir/previous"
printf '部署完成,当前镜像标签:%s\n' "$image_tag"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
echo '用法:generate-env.sh --public-host admin.example.com --files-host files.example.com [--output /etc/lehuo/lehuo.env]'
}
public_host=''
files_host=''
output='/etc/lehuo/lehuo.env'
while (($#)); do
case "$1" in
--public-host) public_host="${2:-}"; shift 2 ;;
--files-host) files_host="${2:-}"; shift 2 ;;
--output) output="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; exit 1 ;;
esac
done
[[ "$public_host" =~ ^[A-Za-z0-9.-]+$ ]] || { echo 'PUBLIC_HOST 格式无效。' >&2; exit 1; }
[[ "$files_host" =~ ^[A-Za-z0-9.-]+$ ]] || { echo 'FILES_HOST 格式无效。' >&2; exit 1; }
[[ "$public_host" != "$files_host" ]] || { echo '管理后台/API 域名与文件域名必须不同。' >&2; exit 1; }
command -v openssl >/dev/null 2>&1 || { echo '缺少 openssl。' >&2; exit 1; }
command -v git >/dev/null 2>&1 || { echo '缺少 git。' >&2; exit 1; }
if [[ -e "$output" ]]; then
printf '拒绝覆盖已有环境文件:%s\n' "$output" >&2
exit 1
fi
install -d -m 0700 "$(dirname -- "$output")"
umask 077
mysql_password="$(openssl rand -hex 24)"
mysql_root_password="$(openssl rand -hex 32)"
redis_password="$(openssl rand -hex 32)"
minio_password="$(openssl rand -hex 32)"
session_secret="$(openssl rand -hex 32)"
session_pepper="$(openssl rand -hex 32)"
audit_key="$(openssl rand -hex 32)"
field_key="$(openssl rand -base64 32 | tr -d '\n')"
image_tag="$(git -C "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" rev-parse --short=12 HEAD)"
cat > "$output" <<EOF
IMAGE_TAG=${image_tag}
PUBLIC_HOST=${public_host}
FILES_HOST=${files_host}
TLS_CERT_PATH=/opt/lehuo/shared/tls/fullchain.pem
TLS_KEY_PATH=/opt/lehuo/shared/tls/privkey.pem
BACKUP_DIR=/opt/lehuo/backups
MYSQL_DATABASE=lehuo
MYSQL_USER=lehuo
MYSQL_PASSWORD=${mysql_password}
MYSQL_ROOT_PASSWORD=${mysql_root_password}
DATABASE_URL=mysql://lehuo:${mysql_password}@mysql:3306/lehuo
REDIS_PASSWORD=${redis_password}
REDIS_URL=redis://:${redis_password}@redis:6379
MINIO_ROOT_USER=lehuo_storage
MINIO_ROOT_PASSWORD=${minio_password}
OBJECT_STORAGE_ENDPOINT=https://${files_host}
OBJECT_STORAGE_REGION=auto
OBJECT_STORAGE_BUCKET=lehuo-private
OBJECT_STORAGE_ACCESS_KEY=lehuo_storage
OBJECT_STORAGE_SECRET_KEY=${minio_password}
SESSION_SECRET=${session_secret}
SESSION_TOKEN_PEPPER=${session_pepper}
AUDIT_IP_HASH_KEY=${audit_key}
FIELD_ENCRYPTION_KEY=${field_key}
ADMIN_WEB_ORIGIN=https://${public_host}
AUTH_SESSION_TTL_SECONDS=28800
WECHAT_SESSION_TTL_SECONDS=7200
WECHAT_APP_ID=
WECHAT_APP_SECRET=
WECHAT_TEMPLATE_APPLICATION_SUBMITTED=
WECHAT_TEMPLATE_APPLICATION_ACCEPTED=
WECHAT_TEMPLATE_SERVICE_ARRANGED=
WECHAT_TEMPLATE_SERVICE_COMPLETED=
WECHAT_TEMPLATE_FOLLOW_UP_PENDING=
WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT=
NOTIFICATION_WORKER_ENABLED=false
EOF
chmod 600 "$output"
install -d -m 0700 /opt/lehuo/shared/tls /opt/lehuo/backups /opt/lehuo/releases
printf '环境文件已生成:%s\n' "$output"
echo '下一步:放置覆盖两个域名的 TLS 证书,然后运行 preflight.sh。'
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ "${EUID}" -ne 0 ]]; then
echo '请使用 sudo 运行此脚本。' >&2
exit 1
fi
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ubuntu_dir="$(cd -- "$script_dir/.." && pwd)"
repo_root="$(cd -- "$ubuntu_dir/../.." && pwd)"
[[ "$repo_root" == '/opt/lehuo/app' ]] || {
echo '安装定时器前请把仓库部署到 /opt/lehuo/app。' >&2
exit 1
}
[[ -f /etc/lehuo/lehuo.env ]] || { echo '缺少 /etc/lehuo/lehuo.env。' >&2; exit 1; }
install -m 0644 "$ubuntu_dir/systemd/lehuo-backup.service" /etc/systemd/system/lehuo-backup.service
install -m 0644 "$ubuntu_dir/systemd/lehuo-backup.timer" /etc/systemd/system/lehuo-backup.timer
systemctl daemon-reload
systemctl enable --now lehuo-backup.timer
systemctl list-timers lehuo-backup.timer --no-pager
echo '每日备份定时器已安装。首次正式发布前仍须手工执行一次备份和恢复演练。'
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ "${EUID}" -ne 0 ]]; then
echo '请使用 sudo 运行此脚本。' >&2
exit 1
fi
if [[ ! -r /etc/os-release ]]; then
echo '无法识别操作系统。' >&2
exit 1
fi
# shellcheck disable=SC1091
source /etc/os-release
if [[ "${ID:-}" != 'ubuntu' || "${VERSION_ID:-}" != '26.04' ]]; then
printf '本部署包仅验证 Ubuntu 26.04;当前为 %s %s。\n' "${ID:-unknown}" "${VERSION_ID:-unknown}" >&2
exit 1
fi
if docker version >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
echo 'Docker Engine 与 Compose plugin 已安装。'
exit 0
fi
conflicts=()
for package in docker.io docker-compose docker-compose-v2 podman-docker containerd runc; do
if dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q 'install ok installed'; then
conflicts+=("$package")
fi
done
if ((${#conflicts[@]})); then
printf '检测到可能冲突的软件包:%s\n' "${conflicts[*]}" >&2
echo '请先人工确认并按 Docker 官方文档移除冲突包,再重新运行;脚本不会自动卸载现有容器运行时。' >&2
exit 1
fi
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
architecture="$(dpkg --print-architecture)"
codename="${UBUNTU_CODENAME:-${VERSION_CODENAME}}"
cat > /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: ${codename}
Components: stable
Architectures: ${architecture}
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
docker version
docker compose version
echo 'Docker 安装完成。Docker 组等同 root 权限,本脚本不会自动把普通用户加入 docker 组。'
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ubuntu_dir() {
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd
}
repo_root() {
cd -- "$(ubuntu_dir)/../.." && pwd
}
compose_file() {
printf '%s/compose.yaml\n' "$(ubuntu_dir)"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || {
printf '缺少命令:%s\n' "$1" >&2
exit 1
}
}
require_env_file() {
local file="$1"
[[ -f "$file" ]] || {
printf '环境文件不存在:%s\n' "$file" >&2
exit 1
}
local mode
mode="$(stat -c '%a' "$file")"
if [[ "$mode" != '600' && "$mode" != '400' ]]; then
printf '环境文件权限必须是 600 或 400,当前为 %s:%s\n' "$mode" "$file" >&2
exit 1
fi
}
env_value() {
local file="$1"
local key="$2"
sed -n "s/^${key}=//p" "$file" | tail -n 1
}
compose() {
local env_file="$1"
shift
docker compose --project-directory "$(ubuntu_dir)" --env-file "$env_file" -f "$(compose_file)" "$@"
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=lib.sh
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
env_file="${1:-/etc/lehuo/lehuo.env}"
require_command docker
require_command curl
require_command openssl
require_env_file "$env_file"
docker version >/dev/null
docker compose version >/dev/null
required_keys=(IMAGE_TAG PUBLIC_HOST FILES_HOST TLS_CERT_PATH TLS_KEY_PATH BACKUP_DIR DATABASE_URL REDIS_URL FIELD_ENCRYPTION_KEY ADMIN_WEB_ORIGIN)
for key in "${required_keys[@]}"; do
value="$(env_value "$env_file" "$key")"
[[ -n "$value" && "$value" != replace-* ]] || {
printf '环境变量缺失或仍为占位值:%s\n' "$key" >&2
exit 1
}
done
image_tag="$(env_value "$env_file" IMAGE_TAG)"
[[ "$image_tag" != 'latest' ]] || { echo '禁止使用 latest 镜像标签。' >&2; exit 1; }
public_host="$(env_value "$env_file" PUBLIC_HOST)"
files_host="$(env_value "$env_file" FILES_HOST)"
[[ "$public_host" != "$files_host" ]] || { echo 'PUBLIC_HOST 与 FILES_HOST 必须不同。' >&2; exit 1; }
cert_path="$(env_value "$env_file" TLS_CERT_PATH)"
key_path="$(env_value "$env_file" TLS_KEY_PATH)"
[[ -r "$cert_path" && -r "$key_path" ]] || { echo 'TLS 证书或私钥不存在/不可读。' >&2; exit 1; }
openssl x509 -in "$cert_path" -noout -checkend 604800 >/dev/null || {
echo 'TLS 证书将在 7 天内过期或无效。' >&2
exit 1
}
openssl x509 -in "$cert_path" -noout -checkhost "$public_host" >/dev/null || {
printf 'TLS 证书不包含管理端域名:%s\n' "$public_host" >&2
exit 1
}
openssl x509 -in "$cert_path" -noout -checkhost "$files_host" >/dev/null || {
printf 'TLS 证书不包含文件域名:%s\n' "$files_host" >&2
exit 1
}
openssl pkey -in "$key_path" -noout -check >/dev/null
compose "$env_file" config --quiet
available_kb="$(df -Pk "$(repo_root)" | awk 'NR==2 {print $4}')"
((available_kb >= 10 * 1024 * 1024)) || { echo '可用磁盘不足 10 GiB。' >&2; exit 1; }
echo 'Ubuntu 生产部署预检通过。'
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=lib.sh
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
usage() {
echo '用法:restore.sh <backup.sql.gz> <target_database> --confirm <target_database> [env-file]'
}
backup="${1:-}"
target_database="${2:-}"
confirm_flag="${3:-}"
confirm_database="${4:-}"
env_file="${5:-/etc/lehuo/lehuo.env}"
[[ -n "$backup" && -n "$target_database" && "$confirm_flag" == '--confirm' && "$confirm_database" == "$target_database" ]] || {
usage >&2
exit 1
}
[[ "$target_database" =~ ^[A-Za-z0-9_]+$ ]] || { echo '目标数据库名无效。' >&2; exit 1; }
require_command docker
require_command gzip
require_command sha256sum
require_command realpath
require_env_file "$env_file"
backup_dir="$(realpath -m "$(env_value "$env_file" BACKUP_DIR)")"
backup_path="$(realpath -e "$backup")"
[[ "$backup_path" == "$backup_dir"/* ]] || { echo '备份文件必须位于 BACKUP_DIR。' >&2; exit 1; }
[[ -f "${backup_path}.sha256" ]] || { echo '缺少 SHA-256 校验文件。' >&2; exit 1; }
(cd -- "$backup_dir" && sha256sum -c "$(basename -- "${backup_path}.sha256")")
gzip -t "$backup_path"
production_database="$(env_value "$env_file" MYSQL_DATABASE)"
if [[ "$target_database" == "$production_database" && "${ALLOW_PRODUCTION_RESTORE:-}" != 'YES' ]]; then
echo '拒绝覆盖生产数据库。若已完成停机、审批和二次备份,请显式设置 ALLOW_PRODUCTION_RESTORE=YES。' >&2
exit 1
fi
compose "$env_file" exec -T -e RESTORE_DATABASE="$target_database" mysql sh -c \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -u root -e "CREATE DATABASE IF NOT EXISTS \`$RESTORE_DATABASE\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"'
gzip -dc "$backup_path" | compose "$env_file" exec -T -e RESTORE_DATABASE="$target_database" mysql sh -c \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -u root "$RESTORE_DATABASE"'
printf '恢复完成:%s → %s\n' "$backup_path" "$target_database"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=lib.sh
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
env_file="${1:-/etc/lehuo/lehuo.env}"
require_env_file "$env_file"
release_dir='/opt/lehuo/releases'
[[ -f "$release_dir/previous" ]] || { echo '没有可回滚的上一镜像标签。' >&2; exit 1; }
previous_tag="$(<"$release_dir/previous")"
current_tag="$(<"$release_dir/current")"
[[ -n "$previous_tag" && "$previous_tag" != 'latest' ]] || { echo '上一镜像标签无效。' >&2; exit 1; }
if ! docker image inspect "lehuo-api:${previous_tag}" >/dev/null 2>&1 || ! docker image inspect "lehuo-admin:${previous_tag}" >/dev/null 2>&1; then
echo '上一版本镜像在本机不存在,停止回滚。' >&2
exit 1
fi
echo '注意:此操作只回滚应用镜像,不回滚数据库。migration 必须保持向后兼容。'
IMAGE_TAG="$previous_tag" compose "$env_file" up -d --no-build api web
bash "$(dirname -- "${BASH_SOURCE[0]}")/smoke.sh" "$env_file"
printf '%s\n' "$previous_tag" > "$release_dir/current"
printf '%s\n' "$current_tag" > "$release_dir/previous"
chmod 600 "$release_dir/current" "$release_dir/previous"
printf '应用已回滚到:%s\n' "$previous_tag"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=lib.sh
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
env_file="${1:-/etc/lehuo/lehuo.env}"
require_command curl
require_env_file "$env_file"
public_host="$(env_value "$env_file" PUBLIC_HOST)"
files_host="$(env_value "$env_file" FILES_HOST)"
curl --fail --silent --show-error --max-time 15 "https://${public_host}/health/live" >/dev/null
curl --fail --silent --show-error --max-time 15 "https://${public_host}/health/ready" >/dev/null
curl --fail --silent --show-error --max-time 15 "https://${public_host}/" >/dev/null
curl --fail --silent --show-error --max-time 15 "https://${files_host}/minio/health/live" >/dev/null
echo 'HTTPS、API readiness、管理后台与对象存储冒烟检查通过。'