优化部署流程

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

+2 -10
View File
@@ -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 "$@"
+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())
File renamed without changes.
+10 -2
View File
@@ -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.')
+90
View File
@@ -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
View File
@@ -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'))
+119
View File
@@ -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__':
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec python3 "$SCRIPT_DIR/deployment.py" uninstall "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec python3 "$SCRIPT_DIR/deployment.py" update "$@"