feat: add persistent local Compose integration environment

This commit is contained in:
2026-09-27 09:35:49 +08:00
parent 2ec13bf662
commit 98ff028228
6 changed files with 380 additions and 0 deletions

173
scripts/orbit-local.py Executable file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""部署持久化的本地联调环境;私密配置只保存在 .local/compose-test。"""
import datetime
import json
import os
from pathlib import Path
import secrets
import subprocess
import sys
import time
import urllib.error
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
PRIVATE = ROOT / '.local' / 'compose-test'
ENV_FILE = PRIVATE / 'compose.env'
CREDENTIALS = PRIVATE / 'sdk.credentials.json'
BASE_URL = 'http://127.0.0.1:18080'
def private_write(path, value):
temporary = path.with_name(path.name + '.tmp')
with temporary.open('w', encoding='utf-8') as file:
os.chmod(temporary, 0o600)
file.write(value)
temporary.replace(path)
def settings():
return dict(line.split('=', 1) for line in ENV_FILE.read_text().splitlines()
if line and not line.startswith('#'))
def save_settings(values):
private_write(ENV_FILE, ''.join(f'{key}={value}\n' for key, value in values.items()))
def compose(*args, **kwargs):
# 不继承其他联调项目的 ORBIT_/COMPOSE_,防止配置串到旧库或旧端口。
env = {k: v for k, v in os.environ.items()
if not k.startswith(('ORBIT_', 'COMPOSE_'))}
env.update(settings())
env.update(ORBIT_LOCAL_DIRECTORY=str(PRIVATE),
ORBIT_LOCAL_USER=f'{os.getuid()}:{os.getgid()}')
# 本地环境使用 Docker 自带构建器,不占用用户选中的跨平台构建器。
env.update(BUILDX_BUILDER=subprocess.check_output(
['docker', 'context', 'show'], text=True).strip(), COMPOSE_BAKE='false')
command = ['docker', 'compose', '--project-name', 'orbit-test',
'--env-file', str(ENV_FILE), '-f', str(ROOT / 'deploy/docker-compose.yml'),
'-f', str(ROOT / 'deploy/local/compose.yml'), *args]
return subprocess.run(command, cwd=ROOT, env=env, check=True, **kwargs)
def wait_ready():
for _ in range(60):
try:
with urllib.request.urlopen(BASE_URL + '/health/ready', timeout=2) as response:
if response.status == 200 and json.load(response)['status'] == 'ready':
return
except (urllib.error.URLError, TimeoutError, OSError):
pass
time.sleep(1)
raise RuntimeError('Orbit readiness 未通过;使用 scripts/orbit-local.py logs orbit 排查。')
def setup():
os.umask(0o077)
PRIVATE.mkdir(parents=True, exist_ok=True, mode=0o700)
if not ENV_FILE.exists():
# 配置缺失而卷仍存在时不能换密码,先恢复旧配置。
volumes = subprocess.check_output(
['docker', 'volume', 'ls', '--filter', 'label=com.docker.compose.project=orbit-test', '-q'],
text=True).strip()
if volumes or any(PRIVATE.iterdir()):
raise RuntimeError('发现既有卷或初始化文件;请恢复 compose.env,不会生成替代密码。')
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
save_settings({
'ORBIT_IMAGE': 'orbit:local-test', 'ORBIT_DB_NAME': 'orbit',
'ORBIT_DB_USER': 'orbit_runtime', 'ORBIT_DB_PASSWORD': secrets.token_hex(24),
'ORBIT_DB_ROOT_PASSWORD': secrets.token_hex(24),
'ORBIT_HTTP_BIND': '127.0.0.1', 'ORBIT_HTTP_PORT': '18080',
'ORBIT_TEST_BACKEND_SUBNET': '10.253.240.0/24',
'ORBIT_TEST_SERVICES_SUBNET': '10.253.241.0/24',
'ORBIT_CONSOLE_QUEUE_ID': '', 'ORBIT_ARCHIVE_STORAGE_ID': secrets.token_hex(16),
'ORBIT_BUILD_VERSION': 'v0.1.1-local', 'ORBIT_BUILD_REVISION': revision,
'ORBIT_BUILD_TIME': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')})
private_write(PRIVATE / 'admin.password', secrets.token_urlsafe(24) + '\n')
values = settings()
values['ORBIT_BUILD_REVISION'] = subprocess.check_output(
['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
save_settings(values)
compose('config', '--quiet')
compose('build', 'orbit')
compose('up', '-d', 'mysql', 'verify', 'archive-init')
# 显式再核验一次,失败不初始化身份、不启动流量。
compose('run', '--rm', '-T', '--no-deps', 'verify')
if not CREDENTIALS.exists():
marker = PRIVATE / 'bootstrap.started'
if marker.exists():
raise RuntimeError('上次初始化未确认;保留现场核查,不能重新创建管理员/凭据。')
password = (PRIVATE / 'admin.password').read_text()
private_write(marker, 'bootstrap started; retain credentials on uncertain commit\n')
compose('run', '--rm', '-T', '--no-deps', 'bootstrap', 'bootstrap',
'--application', 'local-testing', '--namespace', 'default', '--environment', 'test',
'--queue', 'integration', '--worker-access', '--schedule-access', '--event-access',
'--recovery-access', '--resource-access', '--workflow-access', '--workflow-run-access',
'--workflow-control-access', '--workflow-signal-access', '--workflow-recovery-access',
'--workflow-audit-access', '--console-username', 'admin',
'--console-display-name', 'Orbit 测试管理员', '--console-password-stdin',
'--output', '/run/local/sdk.credentials.json', input=password, text=True)
credential = json.loads(CREDENTIALS.read_text())
# 私密回执可能在 COMMIT 确认前写出;重启先核验库中同一身份,不重新 bootstrap。
status = compose('run', '--rm', '-T', '--no-deps', 'bootstrap', 'credential', 'status',
'--namespace-id', credential['namespace_id'], '--credential-id', credential['credential_id'],
stdout=subprocess.PIPE, text=True)
status = json.loads(status.stdout)
expired = status.get('expires_at') and datetime.datetime.fromisoformat(
status['expires_at'].replace('Z', '+00:00')) <= datetime.datetime.now(datetime.timezone.utc)
if status.get('revoked_at') or expired or not status['namespace_enabled'] or not status['application_enabled']:
raise RuntimeError('原凭据已停用或过期;请按凭据轮换手册处理,数据与原身份保持不变。')
values = settings()
values['ORBIT_CONSOLE_QUEUE_ID'] = credential['queue_id']
save_settings(values)
for name, url in [('host.env', BASE_URL), ('docker.env', 'http://orbit-test:8080')]:
private_write(PRIVATE / name, '\n'.join([
'ORBIT_URL=' + url, 'ORBIT_TOKEN=' + credential['token'],
'ORBIT_NAMESPACE_ID=' + credential['namespace_id'],
'ORBIT_QUEUE_ID=' + credential['queue_id'], '']) )
compose('up', '-d', 'orbit')
wait_ready()
request = urllib.request.Request(BASE_URL + '/v1/queues/' + credential['queue_id'],
headers={'Authorization': 'Bearer ' + credential['token']})
with urllib.request.urlopen(request, timeout=5) as response:
if response.status != 200:
raise RuntimeError('已保存的 SDK 凭据未通过队列访问校验。')
print('Orbit ready: ' + BASE_URL + '/console/')
print('Docker network: orbit-test-services; API: http://orbit-test:8080')
print('Admin: admin; password file: ' + str(PRIVATE / 'admin.password'))
print('SDK settings: ' + str(PRIVATE / 'host.env') + ' / docker.env')
print('Credential expires_at: ' + credential['expires_at'])
def main():
action = sys.argv[1] if len(sys.argv) > 1 else 'status'
if action == 'up':
if len(sys.argv) > 2:
raise RuntimeError('用法:scripts/orbit-local.py up')
setup()
return
if not ENV_FILE.exists():
raise RuntimeError('请先运行 scripts/orbit-local.py up。')
if action in ('status', 'ps'):
compose('ps', '--all')
elif action == 'logs':
compose('logs', '--tail', '100', *(sys.argv[2:] or ['orbit']))
elif action == 'stop':
compose('stop', 'orbit', 'mysql')
elif action == 'start':
compose('start', 'mysql', 'orbit')
wait_ready()
elif action == 'restart':
compose('restart', 'orbit')
wait_ready()
else:
raise RuntimeError('用法:scripts/orbit-local.py up|status|logs [service]|stop|start|restart')
if __name__ == '__main__':
try:
main()
except (RuntimeError, subprocess.CalledProcessError) as error:
print(str(error), file=sys.stderr)
sys.exit(1)