186 lines
9.3 KiB
Python
Executable File
186 lines
9.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""部署持久化的本地联调环境;私密配置只保存在 .local/compose-test。"""
|
||
import datetime
|
||
import base64
|
||
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.2-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()
|
||
key_file = PRIVATE / 'credential-keys.json'
|
||
if not key_file.exists():
|
||
# 已启用后丢失主密钥,必须恢复备份,不能生成替代密钥使已有密文不可读。
|
||
key_volumes = subprocess.check_output(['docker', 'volume', 'ls', '--filter', 'name=orbit-test_orbit-keys', '-q'], text=True).strip()
|
||
if values.get('ORBIT_CREDENTIAL_VAULT_INITIALIZED') or key_volumes:
|
||
raise RuntimeError('Token 保管密钥缺失,请恢复 credential-keys.json;不会替换既有密钥。')
|
||
private_write(key_file, json.dumps({'active': 'local-v1', 'keys': {'local-v1': base64.b64encode(secrets.token_bytes(32)).decode()}}) + '\n')
|
||
values['ORBIT_CREDENTIAL_VAULT_INITIALIZED'] = '1'
|
||
values['ORBIT_BUILD_REVISION'] = subprocess.check_output(
|
||
['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
|
||
values['ORBIT_BUILD_TIME'] = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||
save_settings(values)
|
||
compose('config', '--quiet')
|
||
compose('build', 'orbit')
|
||
# 完成构建后再优雅停止旧写节点,避免迁移期间混用凭据存储版本。
|
||
compose('stop', 'orbit')
|
||
compose('up', '-d', 'mysql', 'verify', 'archive-init', 'credential-key-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)
|