feat: add persistent local Compose integration environment
This commit is contained in:
173
scripts/orbit-local.py
Executable file
173
scripts/orbit-local.py
Executable 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)
|
||||
Reference in New Issue
Block a user