Files
ChuBlog/scripts/smoke-deployment.py
T

90 lines
4.9 KiB
Python
Raw Normal View History

2026-10-17 14:21:52 +08:00
#!/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)