优化部署流程
This commit is contained in:
1 parent
16e5db3b26
commit
2ecc72d027
20 files changed
+597
-116
No files matched your search
+2
-10
@@ -1,12 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Initialize dependencies via GitHub HTTPS with self-hosted HTTPS fallback, then build Docker Compose.
|
||||
set -eu
|
||||
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
python3 "$PROJECT_ROOT/scripts/init-deps.py"
|
||||
if [ ! -f "$PROJECT_ROOT/.env" ]; then
|
||||
printf '%s\n' 'Create and configure .env first; see docs/deployment.md.' >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 "$PROJECT_ROOT/scripts/prepare-deploy.py"
|
||||
docker compose --env-file "$PROJECT_ROOT/.env" -f "$PROJECT_ROOT/deploy/compose.yaml" up -d --no-build
|
||||
printf '%s\n' 'When the database is ready, run the documented db-init command once.'
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec python3 "$SCRIPT_DIR/deployment.py" deploy "$@"
|
||||
@@ -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())
|
||||
File renamed without changes.
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build deployment images inside Docker; no host Rust or Haskell tools required."""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -10,12 +11,13 @@ parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--env-file', type=Path, help='Compose environment file (default: .env, or .env.example when absent)')
|
||||
parser.add_argument('--build-database', action='store_true', help='compatibility option; database is always built inside Docker')
|
||||
parser.add_argument('--sources-env-file', type=Path, help='optional second Compose env file overriding build download sources')
|
||||
parser.add_argument('--project-name', help='Compose project name')
|
||||
args = parser.parse_args()
|
||||
if shutil.which('docker') is None:
|
||||
parser.error('Docker with the Compose plugin is required; see docs/deployment.md.')
|
||||
for manifest in ['external/ChuSQL-RustDriver/Cargo.toml', 'external/ChuSQL/chusql-core/storage/Cargo.toml']:
|
||||
if not (root / manifest).is_file():
|
||||
parser.error('Initialize external dependencies with python3 scripts/init-deps.py first.')
|
||||
parser.error('Initialize external dependencies with python3 scripts/fetch-deps.py first.')
|
||||
envfile = args.env_file or (root / '.env' if (root / '.env').is_file() else root / '.env.example')
|
||||
if not envfile.is_file():
|
||||
parser.error(f'Environment file does not exist: {envfile}')
|
||||
@@ -24,12 +26,18 @@ if args.sources_env_file:
|
||||
if not args.sources_env_file.is_file():
|
||||
parser.error(f'Source configuration does not exist: {args.sources_env_file}')
|
||||
command.extend(['--env-file', str(args.sources_env_file.resolve())])
|
||||
if args.project_name:
|
||||
command.extend(['-p', args.project_name])
|
||||
command.extend([
|
||||
'-f', str(root / 'deploy/compose.yaml'),
|
||||
'build', 'init', 'api', 'ssh', 'db', 'db-init', 'web',
|
||||
])
|
||||
# Build-only parsing needs values even before deploy.sh creates runtime credentials.
|
||||
build_env = os.environ.copy()
|
||||
for key in ['CHUSQL_ROOT_PASSWORD', 'CHUSQL_PASSWORD']:
|
||||
build_env[key] = build_env.get(key) or 'unused-build-placeholder'
|
||||
try:
|
||||
subprocess.run(command, cwd=root, check=True)
|
||||
subprocess.run(command, cwd=root, check=True, env=build_env)
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise SystemExit(error.returncode) from None
|
||||
print('Deployment images built inside Docker. No host binaries were created.')
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the one-command blog deployment and uninstall on isolated Docker resources."""
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--sources-env-file', type=Path, default=ROOT / 'deploy/sources.public.env')
|
||||
args = parser.parse_args()
|
||||
project = 'chublog-lifecycle-' + secrets.token_hex(4)
|
||||
origin = 'http://localhost:18080'
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if key not in ['COMPOSE_PROFILES', 'COMPOSE_PROJECT_NAME', 'PUBLIC_ORIGIN', 'SITE_ADDRESS',
|
||||
'CHUSQL_ROOT_PASSWORD', 'CHUSQL_PASSWORD', 'HTTP_PORT', 'HTTPS_PORT',
|
||||
'REGISTRY_PREFIX', 'DEBIAN_MIRROR', 'NPM_REGISTRY', 'CARGO_MIRROR_INDEX']}
|
||||
|
||||
|
||||
def run(command, **kwargs):
|
||||
return subprocess.run(command, cwd=ROOT, env=env, check=True, text=True, **kwargs)
|
||||
|
||||
|
||||
def read_page(path):
|
||||
with urllib.request.urlopen(origin + path, timeout=10) as response:
|
||||
return response.geturl(), response.read().decode()
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='chublog-lifecycle-') as temporary:
|
||||
config = Path(temporary) / '.env'
|
||||
config.write_text('HTTP_BIND=127.0.0.1\nHTTPS_PORT=18443\nSSH_BIND=127.0.0.1\nSSH_PORT=12222\n')
|
||||
options = ['--env-file', str(config), '--project-name', project,
|
||||
'--sources-env-file', str(args.sources_env_file.resolve())]
|
||||
compose = ['docker', 'compose', '--env-file', str(config), '--env-file',
|
||||
str(args.sources_env_file.resolve()), '-p', project, '-f', str(ROOT / 'deploy/compose.yaml')]
|
||||
try:
|
||||
run([str(ROOT / 'scripts/deploy.sh'), *options, '--site', origin], stdin=subprocess.DEVNULL)
|
||||
initial = config.read_text()
|
||||
assert 'ADMIN_PASSWORD_HASH' not in initial
|
||||
assert config.stat().st_mode & 0o777 == 0o600
|
||||
_, html = read_page('/')
|
||||
assert '个人博客正在施工中' in html
|
||||
assert '阅读文章' in html
|
||||
assert '文章' in read_page('/articles')[1]
|
||||
assert '登录' in read_page('/login')[1]
|
||||
assert json.loads(read_page('/api/health')[1])
|
||||
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||
password = secrets.token_urlsafe(24)
|
||||
|
||||
def call(path, data=None):
|
||||
request = urllib.request.Request(origin + path,
|
||||
data=json.dumps(data).encode() if data is not None else None,
|
||||
headers={'Origin': origin, 'Content-Type': 'application/json'})
|
||||
with client.open(request, timeout=10) as response:
|
||||
return json.load(response)
|
||||
|
||||
assert call('/api/auth/register', {'username': 'first', 'password': password})['role'] == 'reader'
|
||||
assert call('/api/auth/login', {'username': 'first', 'password': password})['csrf']
|
||||
try:
|
||||
call('/api/admin/users')
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == 403
|
||||
else:
|
||||
raise AssertionError('First user acquired administrator permissions')
|
||||
running = run(compose + ['ps', '--services', '--status', 'running'], capture_output=True).stdout.split()
|
||||
assert set(running) == {'db', 'api', 'ssh', 'web', 'caddy'}, running
|
||||
run(compose + ['run', '--rm', '--no-deps', '-T', 'db-init'], capture_output=True)
|
||||
run([str(ROOT / 'scripts/uninstall.sh'), *options], stdin=subprocess.DEVNULL)
|
||||
assert run(['docker', 'volume', 'ls', '-q', '--filter',
|
||||
f'label=com.docker.compose.project={project}'], capture_output=True).stdout.strip()
|
||||
run([str(ROOT / 'scripts/deploy.sh'), *options], stdin=subprocess.DEVNULL)
|
||||
assert config.read_text() == initial, 'Redeploy rotated credentials'
|
||||
assert '个人博客正在施工中' in read_page('/')[1]
|
||||
assert call('/api/auth/login', {'username': 'first', 'password': password})['csrf']
|
||||
print('PASS: one-command build, DB initialization and full blog without an initial administrator', flush=True)
|
||||
print('PASS: reading, registration/login, SSH service and first-user permission boundary', flush=True)
|
||||
print('PASS: repeat DB provisioning and data-preserving uninstall/redeploy retain credentials', flush=True)
|
||||
finally:
|
||||
run([str(ROOT / 'scripts/uninstall.sh'), *options, '--purge'], stdin=subprocess.DEVNULL)
|
||||
assert not run(['docker', 'ps', '-aq', '--filter',
|
||||
f'label=com.docker.compose.project={project}'], capture_output=True).stdout.strip()
|
||||
assert not run(['docker', 'volume', 'ls', '-q', '--filter',
|
||||
f'label=com.docker.compose.project={project}'], capture_output=True).stdout.strip()
|
||||
print('PASS: purge removes only the isolated project containers and volumes', flush=True)
|
||||
+11
-3
@@ -67,9 +67,8 @@ class Client:
|
||||
with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary:
|
||||
tmp = Path(temporary)
|
||||
password = secrets.token_urlsafe(24)
|
||||
hashed = 'temporary-build-placeholder'
|
||||
envfile = tmp / '.env'
|
||||
envfile.write_text(f"PUBLIC_ORIGIN={ORIGIN}\nSITE_ADDRESS=http://localhost:80\nADMIN_PASSWORD_HASH='{hashed}'\n"
|
||||
envfile.write_text(f"PUBLIC_ORIGIN={ORIGIN}\nSITE_ADDRESS=http://localhost:80\n"
|
||||
f"CHUSQL_ROOT_PASSWORD={secrets.token_urlsafe(24)}\nCHUSQL_PASSWORD={secrets.token_urlsafe(24)}\n"
|
||||
f"REGISTRY_PREFIX={os.environ.get('REGISTRY_PREFIX', '')}\n"
|
||||
'DEBIAN_MIRROR=mirrors.ustc.edu.cn\nALLOW_REGISTRATION=true\nHTTP_BIND=127.0.0.1\nHTTP_PORT=18080\nHTTPS_PORT=18443\nSSH_BIND=127.0.0.1\nSSH_PORT=12222\n')
|
||||
@@ -105,7 +104,16 @@ printf '%s\n' "$reply"
|
||||
hashed = run(compose + ['run', '--rm', '--no-deps', '-T', 'api', 'hash-password'],
|
||||
input=password, capture_output=True).stdout.strip()
|
||||
assert hashed.startswith('$argon2id$'), 'Container did not produce an Argon2id hash'
|
||||
envfile.write_text(envfile.read_text().replace('temporary-build-placeholder', hashed))
|
||||
# Isolated development fixture; production deployment never creates an administrator.
|
||||
run(compose + ['run', '--rm', '--no-deps', '-T', 'init'])
|
||||
for path, data, mode in [
|
||||
('/data/users.json', {'admin': {'hash': hashed}}, '600'),
|
||||
('/auth/publishers.json', {'repository': '/srv/ingress/authors', 'publishers': {
|
||||
'admin': {'enabled': True, 'role': 'admin', 'prefixes': [], 'ssh_key': None}}}, '644'),
|
||||
]:
|
||||
run(compose + ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'api', '-c',
|
||||
f'umask 077; cat > {path} && chmod {mode} {path}'],
|
||||
input=json.dumps(data), capture_output=True)
|
||||
run(compose + ['up', '-d', '--no-build'])
|
||||
admin = Client()
|
||||
wait_for(lambda: admin.call('GET', '/api/health'))
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check unattended deployment, safe updates, and scoped uninstall without Docker."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class Deployment(unittest.TestCase):
|
||||
def test_deploy_update_and_uninstall_lifecycle(self):
|
||||
with tempfile.TemporaryDirectory(prefix='chublog lifecycle ') as temporary:
|
||||
root = Path(temporary)
|
||||
(root / 'scripts').mkdir()
|
||||
for name in ['deployment.py', 'prepare-deploy.py', 'deploy.sh', 'update.sh', 'uninstall.sh']:
|
||||
shutil.copy2(ROOT / 'scripts' / name, root / 'scripts' / name)
|
||||
shutil.copy2(ROOT / '.env.example', root / '.env.example')
|
||||
for name in ['external/ChuSQL-RustDriver/Cargo.toml', 'external/ChuSQL/chusql-core/storage/Cargo.toml']:
|
||||
manifest = root / name
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.touch()
|
||||
(root / 'scripts/fetch-deps.py').write_text(
|
||||
'import os\nfrom pathlib import Path\n'
|
||||
'with Path(os.environ["CALL_LOG"]).open("a") as log: log.write(\'["fetch"]\\n\')\n')
|
||||
tools = root / 'tools'
|
||||
tools.mkdir()
|
||||
program = f'''#!{sys.executable}
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
kind = Path(sys.argv[0]).name
|
||||
args = sys.argv[1:]
|
||||
with Path(os.environ['CALL_LOG']).open('a') as log:
|
||||
log.write(json.dumps([kind, *args]) + '\\n')
|
||||
if kind == 'git' and args[:2] == ['status', '--porcelain']:
|
||||
print(os.environ.get('GIT_DIRTY', ''), end='')
|
||||
'''
|
||||
for name in ['docker', 'git']:
|
||||
tool = tools / name
|
||||
tool.write_text(program)
|
||||
tool.chmod(0o755)
|
||||
log = root / 'calls.jsonl'
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if key not in ['CHUSQL_ROOT_PASSWORD', 'CHUSQL_PASSWORD']}
|
||||
env.update(PATH=str(tools) + os.pathsep + env['PATH'], CALL_LOG=str(log))
|
||||
|
||||
def invoke(name, *args, extra_env=None):
|
||||
return subprocess.run([str(root / 'scripts' / name), *args], cwd=root,
|
||||
env={**env, **(extra_env or {})}, stdin=subprocess.DEVNULL,
|
||||
capture_output=True, text=True)
|
||||
|
||||
result = invoke('deploy.sh', '--site', 'https://blog.example.com')
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
settings = (root / '.env').read_text()
|
||||
self.assertNotIn('ADMIN_PASSWORD_HASH', settings)
|
||||
self.assertIn('PUBLIC_ORIGIN=https://blog.example.com', settings)
|
||||
self.assertEqual((root / '.env').stat().st_mode & 0o777, 0o600)
|
||||
passwords = [line.split('=', 1)[1] for line in settings.splitlines()
|
||||
if line.startswith(('CHUSQL_ROOT_PASSWORD=', 'CHUSQL_PASSWORD='))]
|
||||
self.assertTrue(all(len(value) >= 16 for value in passwords))
|
||||
self.assertNotEqual(*passwords)
|
||||
self.assertTrue(all(value not in result.stdout + result.stderr for value in passwords))
|
||||
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
||||
self.assertEqual(calls[0], ['fetch'])
|
||||
actions = [call for call in calls if call[0] == 'docker']
|
||||
self.assertIn('build', actions[0])
|
||||
self.assertTrue({'api', 'ssh', 'db', 'web'}.issubset(actions[0]))
|
||||
self.assertEqual(actions[1][-4:], ['up', '-d', '--no-build', 'db'])
|
||||
self.assertIn('provision-db', actions[2])
|
||||
self.assertIn('--wait', actions[3])
|
||||
self.assertFalse(any('hash-password' in call for call in calls))
|
||||
result = invoke('deploy.sh')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((root / '.env').read_text(), settings)
|
||||
(root / '.env').write_text(settings + 'ADMIN_USERNAME=old-admin\nADMIN_PASSWORD_HASH=old-hash\nALLOW_REGISTRATION=false\n')
|
||||
result = invoke('deploy.sh')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
settings = (root / '.env').read_text()
|
||||
self.assertNotIn('ADMIN_USERNAME=', settings)
|
||||
self.assertNotIn('ADMIN_PASSWORD_HASH=', settings)
|
||||
self.assertIn('ALLOW_REGISTRATION=false', settings)
|
||||
self.assertTrue(all(password in settings for password in passwords))
|
||||
log.write_text('')
|
||||
result = invoke('update.sh', extra_env={'GIT_DIRTY': ' M source.rs\n'})
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn('pull', log.read_text())
|
||||
self.assertNotIn('docker', log.read_text())
|
||||
log.write_text('')
|
||||
result = invoke('update.sh')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
||||
self.assertEqual(calls[:3], [['git', 'status', '--porcelain'], ['git', 'pull', '--ff-only'], ['fetch']])
|
||||
for purge in [False, True]:
|
||||
log.write_text('')
|
||||
result = invoke('uninstall.sh', *(['--purge'] if purge else []))
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn('down', calls[0])
|
||||
self.assertEqual('--volumes' in calls[0], purge)
|
||||
self.assertNotIn('prune', calls[0])
|
||||
self.assertEqual((root / '.env').read_text(), settings)
|
||||
(root / '.env').unlink()
|
||||
result = invoke('uninstall.sh', '--purge')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((root / '.env').exists())
|
||||
bad_sources = root / 'bad.env'
|
||||
bad_sources.write_text('CHUSQL_ROOT_PASSWORD=not-a-download-source\n')
|
||||
result = invoke('deploy.sh', '--sources-env-file', str(bad_sources))
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertFalse((root / '.env').exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -7,7 +7,7 @@ import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPT = Path(__file__).with_name('init-deps.py')
|
||||
SCRIPT = Path(__file__).with_name('fetch-deps.py')
|
||||
|
||||
|
||||
class InitializeDependencies(unittest.TestCase):
|
||||
@@ -17,7 +17,7 @@ class InitializeDependencies(unittest.TestCase):
|
||||
project = root / 'blog'
|
||||
project.mkdir()
|
||||
(project / 'scripts').mkdir()
|
||||
shutil.copy2(SCRIPT, project / 'scripts/init-deps.py')
|
||||
shutil.copy2(SCRIPT, project / 'scripts/fetch-deps.py')
|
||||
env = {**os.environ, 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_CONFIG_NOSYSTEM': '1'}
|
||||
|
||||
def git(cwd, *args):
|
||||
@@ -42,7 +42,7 @@ class InitializeDependencies(unittest.TestCase):
|
||||
sources.append(str(source))
|
||||
(project / '.gitmodules').write_text(''.join(modules))
|
||||
git(project, 'add', '.gitmodules')
|
||||
subprocess.run(['python3', 'scripts/init-deps.py', '--database-repo', sources[0], '--driver-repo', sources[1]], cwd=project, env=env, check=True, capture_output=True)
|
||||
subprocess.run(['python3', 'scripts/fetch-deps.py', '--database-repo', sources[0], '--driver-repo', sources[1]], cwd=project, env=env, check=True, capture_output=True)
|
||||
for name in ['ChuSQL', 'ChuSQL-RustDriver']:
|
||||
self.assertEqual((project / f'external/{name}/source.txt').read_text(), f'{name} committed source')
|
||||
self.assertTrue(all(line.startswith('160000') for line in git(project, 'ls-files', '--stage', 'external').splitlines()))
|
||||
@@ -52,12 +52,12 @@ class InitializeDependencies(unittest.TestCase):
|
||||
git(project, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'Pinned dependencies')
|
||||
git(root, 'clone', '--no-local', str(project), str(fresh))
|
||||
(fresh / 'scripts').mkdir()
|
||||
shutil.copy2(SCRIPT, fresh / 'scripts/init-deps.py')
|
||||
shutil.copy2(SCRIPT, fresh / 'scripts/fetch-deps.py')
|
||||
fallback_env = {**env, 'GIT_ALLOW_PROTOCOL': 'file', 'GIT_CONFIG_COUNT': '2'}
|
||||
for i, (name, source) in enumerate(zip(['ChuSQL', 'ChuSQL-RustDriver'], sources)):
|
||||
fallback_env[f'GIT_CONFIG_KEY_{i}'] = f'url.{Path(source).as_uri()}.insteadOf'
|
||||
fallback_env[f'GIT_CONFIG_VALUE_{i}'] = f'https://github.com/TheBadRoger/{name}.git'
|
||||
subprocess.run(['python3', 'scripts/init-deps.py'], cwd=fresh, env=fallback_env, check=True, capture_output=True)
|
||||
subprocess.run(['python3', 'scripts/fetch-deps.py'], cwd=fresh, env=fallback_env, check=True, capture_output=True)
|
||||
for name in ['ChuSQL', 'ChuSQL-RustDriver']:
|
||||
self.assertEqual((fresh / f'external/{name}/source.txt').read_text(), f'{name} committed source')
|
||||
self.assertEqual(git(fresh / f'external/{name}', 'config', '--get', 'remote.origin.url'), f'https://github.com/TheBadRoger/{name}.git')
|
||||
@@ -66,7 +66,7 @@ class InitializeDependencies(unittest.TestCase):
|
||||
partial = root / 'partial'
|
||||
git(root, 'clone', '--no-local', str(project), str(partial))
|
||||
(partial / 'scripts').mkdir()
|
||||
shutil.copy2(SCRIPT, partial / 'scripts/init-deps.py')
|
||||
shutil.copy2(SCRIPT, partial / 'scripts/fetch-deps.py')
|
||||
partial_env = {**env, 'GIT_ALLOW_PROTOCOL': 'file', 'GIT_CONFIG_COUNT': '4'}
|
||||
for i, (name, source) in enumerate(zip(['ChuSQL', 'ChuSQL-RustDriver'], sources)):
|
||||
empty_source = root / f'empty-{name}.git'
|
||||
@@ -75,13 +75,13 @@ class InitializeDependencies(unittest.TestCase):
|
||||
partial_env[f'GIT_CONFIG_VALUE_{i}'] = f'https://github.com/TheBadRoger/{name}.git'
|
||||
partial_env[f'GIT_CONFIG_KEY_{i + 2}'] = f'url.{Path(source).as_uri()}.insteadOf'
|
||||
partial_env[f'GIT_CONFIG_VALUE_{i + 2}'] = f'https://repo.chunana.cn/ChuNa7a/{name}.git'
|
||||
result = subprocess.run(['python3', 'scripts/init-deps.py'], cwd=partial, env=partial_env, text=True, capture_output=True)
|
||||
result = subprocess.run(['python3', 'scripts/fetch-deps.py'], cwd=partial, env=partial_env, text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
for name in ['ChuSQL', 'ChuSQL-RustDriver']:
|
||||
self.assertEqual((partial / f'external/{name}/source.txt').read_text(), f'{name} committed source')
|
||||
self.assertEqual(git(partial / f'external/{name}', 'config', '--get', 'remote.origin.url'), f'https://repo.chunana.cn/ChuNa7a/{name}.git')
|
||||
# Running the initialization a second time must preserve the same pins.
|
||||
subprocess.run(['python3', 'scripts/init-deps.py', '--database-repo', sources[0], '--driver-repo', sources[1]], cwd=project, env=env, check=True, capture_output=True)
|
||||
subprocess.run(['python3', 'scripts/fetch-deps.py', '--database-repo', sources[0], '--driver-repo', sources[1]], cwd=project, env=env, check=True, capture_output=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec python3 "$SCRIPT_DIR/deployment.py" uninstall "$@"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec python3 "$SCRIPT_DIR/deployment.py" update "$@"
|
||||
Reference in new issue
Block a user