#!/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 expiry: ' + (status.get('expires_at') or 'permanent')) 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)