优化部署流程

This commit is contained in:
ChuNa7a committed 2026-10-17 14:21:52 +08:00
1 parent 16e5db3b26
commit 2ecc72d027
20 files changed
+597 -116

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Deploy the blog, update it, or uninstall its Compose project."""
import argparse
import os
from pathlib import Path
import secrets
import shutil
import subprocess
import sys
import tempfile
import time
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
DB_KEYS = ('CHUSQL_ROOT_PASSWORD', 'CHUSQL_PASSWORD')
RETIRED_KEYS = ('ADMIN_USERNAME', 'ADMIN_PASSWORD_HASH')
def read_config(path):
values = {}
if path.is_file():
for line in path.read_text().splitlines():
key, sep, value = line.partition('=')
if sep and key.strip() and not key.lstrip().startswith('#'):
values[key.strip()] = value.strip().strip("'\"")
return values
def configure(path, site):
text = path.read_text() if path.exists() else (ROOT / '.env.example').read_text()
values = read_config(path if path.exists() else ROOT / '.env.example')
changes = {}
for key in DB_KEYS:
value = os.environ.get(key, values.get(key, ''))
if key in os.environ and (len(value) < 16 or value.startswith('replace-with-')):
raise RuntimeError(f'{key} 环境变量无效,请先取消该覆盖或设置至少 16 字节的口令。')
if not value or value.startswith('replace-with-'):
changes[key] = secrets.token_urlsafe(32)
elif len(value) < 16:
raise RuntimeError(f'{key} 至少需要 16 字节;已有口令不会自动轮换。')
elif value != values.get(key, ''):
if values.get(key) and not values[key].startswith('replace-with-'):
raise RuntimeError(f'{key} 环境覆盖与已保存口令不同,已停止以避免破坏现有数据库访问。')
changes[key] = value
if site:
if any(c.isspace() for c in site):
raise RuntimeError('--site 不能包含空白字符。')
parsed = urlsplit(site if '://' in site else 'https://' + site)
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
or parsed.username or parsed.password or parsed.path not in ('', '/')
or parsed.query or parsed.fragment):
raise RuntimeError('--site 需要站点地址,例如 https://blog.example.com,不带路径。')
changes['PUBLIC_ORIGIN'] = f'{parsed.scheme}://{parsed.netloc}'
host = f'[{parsed.hostname}]' if ':' in parsed.hostname else parsed.hostname
changes['SITE_ADDRESS'] = f'http://{host}:80' if parsed.scheme == 'http' else host
changes['HTTP_PORT' if parsed.scheme == 'http' else 'HTTPS_PORT'] = str(
parsed.port or (80 if parsed.scheme == 'http' else 443))
effective = {**values, **changes}
if os.environ.get(DB_KEYS[0], effective.get(DB_KEYS[0])) == os.environ.get(DB_KEYS[1], effective.get(DB_KEYS[1])):
raise RuntimeError('数据库 root 与博客数据库账号必须使用不同口令。')
seen = set()
lines = []
for line in text.splitlines():
key = line.partition('=')[0].strip()
if key in RETIRED_KEYS:
continue
if key in changes:
line = f'{key}={changes[key]}'
seen.add(key)
lines.append(line)
lines.extend(f'{key}={value}' for key, value in changes.items() if key not in seen)
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=f'.{path.name}.', dir=path.parent)
try:
with os.fdopen(fd, 'w') as config:
os.fchmod(config.fileno(), 0o600)
config.write('\n'.join(lines) + '\n')
config.flush()
os.fsync(config.fileno())
os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
Path(temporary).unlink(missing_ok=True)
print(f'配置已就绪:{path}(权限 600,已有数据库口令保留)', flush=True)
def run(command, **kwargs):
return subprocess.run(command, cwd=ROOT, check=True, **kwargs)
def compose_command(args, envfile):
command = ['docker', 'compose', '--env-file', str(envfile)]
if args.sources_env_file:
command.extend(['--env-file', str(args.sources_env_file.resolve())])
return command + ['-p', args.project_name, '-f', str(ROOT / 'deploy/compose.yaml')]
def deploy(args):
configure(args.env_file, args.site)
print('获取固定版本的外部依赖…', flush=True)
run([sys.executable, str(ROOT / 'scripts/fetch-deps.py')])
print('在 Docker 内构建生产镜像…', flush=True)
build = [sys.executable, str(ROOT / 'scripts/prepare-deploy.py'),
'--env-file', str(args.env_file), '--project-name', args.project_name]
if args.sources_env_file:
build.extend(['--sources-env-file', str(args.sources_env_file.resolve())])
run(build)
compose = compose_command(args, args.env_file)
print('启动数据库并初始化博客数据库与专用账号…', flush=True)
run(compose + ['up', '-d', '--no-build', 'db'])
deadline = time.monotonic() + 120
last_error = ''
while time.monotonic() < deadline:
result = subprocess.run(compose + ['run', '--rm', '--no-deps', '-T', '--entrypoint',
'timeout', 'db-init', '15s', 'chublog-api', 'provision-db'],
cwd=ROOT, text=True, capture_output=True)
if result.returncode == 0:
break
last_error = result.stderr[-3000:]
time.sleep(2)
else:
values = read_config(args.env_file)
for key in DB_KEYS:
for secret in (values.get(key), os.environ.get(key)):
if secret:
last_error = last_error.replace(secret, '***')
raise RuntimeError('数据库初始化失败,部署未完成。检查数据库日志和已有口令。\n' + last_error)
print('启动博客、SSH 与 Caddy…', flush=True)
run(compose + ['up', '-d', '--no-build', '--wait', '--wait-timeout', '90'])
origin = os.environ.get('PUBLIC_ORIGIN', read_config(args.env_file).get('PUBLIC_ORIGIN', ''))
print(f'部署完成:{origin}\n不创建初始管理员;注册账户默认为普通用户。', flush=True)
def update(args):
if run(['git', 'status', '--porcelain'], capture_output=True, text=True).stdout.strip():
raise RuntimeError('工作区有未提交修改,更新已停止;先提交或自行保存修改。')
run(['git', 'pull', '--ff-only'])
# Reload the updated deployment code instead of running an old in-memory copy.
command = [sys.executable, str(ROOT / 'scripts/deployment.py'), 'deploy',
'--env-file', str(args.env_file), '--project-name', args.project_name]
if args.sources_env_file:
command.extend(['--sources-env-file', str(args.sources_env_file.resolve())])
if args.site:
command.extend(['--site', args.site])
os.execv(sys.executable, command)
def uninstall(args):
envfile = args.env_file if args.env_file.is_file() else ROOT / '.env.example'
env = os.environ.copy()
values = read_config(envfile)
# Compose validates these variables even when down does not connect to the DB.
for key in DB_KEYS:
env[key] = env.get(key) or values.get(key) or 'unused-uninstall-placeholder'
command = compose_command(args, envfile) + ['--profile', '*', 'down',
'--rmi', 'local', '--remove-orphans']
if args.purge:
print(f'永久删除项目 {args.project_name} 的数据卷。', flush=True)
command.append('--volumes')
run(command, env=env)
print('卸载完成。' + ('项目数据卷已删除。' if args.purge else '数据卷已保留;彻底删除使用 --purge。'), flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['deploy', 'update', 'uninstall'])
parser.add_argument('--env-file', type=Path, default=ROOT / '.env')
parser.add_argument('--sources-env-file', type=Path, help='optional public/build source overrides')
parser.add_argument('--project-name', default='chublog', help='Compose project name (default: chublog)')
parser.add_argument('--site', help='site URL; a bare hostname uses HTTPS')
parser.add_argument('--purge', action='store_true', help='uninstall only: permanently delete project volumes')
args = parser.parse_args()
if args.purge and args.action != 'uninstall':
parser.error('--purge 只能用于卸载。')
if args.sources_env_file and not args.sources_env_file.is_file():
parser.error('下载源配置文件不存在。')
if args.sources_env_file:
supported = {'REGISTRY_PREFIX', 'DEBIAN_MIRROR', 'NPM_REGISTRY', 'CARGO_MIRROR_INDEX'}
if set(read_config(args.sources_env_file)) - supported:
parser.error('下载源配置只能包含镜像和软件包来源,不能覆盖站点或数据库口令。')
args.env_file = args.env_file.resolve()
if shutil.which('docker') is None:
parser.error('需要安装 Docker Engine 和 Docker Compose 插件。')
try:
{'deploy': deploy, 'update': update, 'uninstall': uninstall}[args.action](args)
except subprocess.CalledProcessError as error:
print(f'操作失败,命令退出码:{error.returncode}', file=sys.stderr)
return error.returncode
except (OSError, ValueError, RuntimeError) as error:
print(f'操作失败:{error}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())