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

View File

@@ -7,3 +7,8 @@ sdk
examples
deploy
*.log
.local
.env
.env.*
!.env.example
**/*.credentials.json

View File

@@ -203,6 +203,8 @@ fmt.Println(current.ID, current.State)
## Docker 与域名部署
本机给其他服务联调,可直接运行 `./scripts/orbit-local.py up`,自动部署到 `18080`,生成管理员和 SDK 配置;完整地址、Docker 网络与启停方法见[本地 Compose 测试环境](deploy/local/README.md)。
仓库提供 [Dockerfile](Dockerfile)、[Compose 启动链](deploy/docker-compose.yml)和 [Nginx 配置](deploy/nginx/orbit.conf.example)。按[完整 Compose 手册](docs/22-compose-deployment.md)注入数据库密码、构建镜像、启动 MySQL/迁移/核验、初始化管理员,最后启动 Orbit。手册也包括停止、更新与两节点覆盖配置。
- Console 同源访问:域名反向代理到 Orbit 后,页面自动使用这个域名,无需在界面填写服务地址。

134
deploy/local/README.md Normal file
View File

@@ -0,0 +1,134 @@
# 本地 Docker Compose 联调环境
一套长期保留数据的 Orbit + MySQL 测试环境。项目名固定为 `orbit-test`,本机端口 `18080`;复用[基础 Compose](../docker-compose.yml),本目录补充镜像构建、跨服务网络和持久归档卷。
## 启动
此部署工具随 `main` 分支交付;使用当前 main 代码,SDK 仍可固定 v0.1.1。需要 Docker Desktop / Docker Compose、Python 3;首次构建镜像需要访问构建依赖。从 Orbit 仓库根目录执行:
```sh
./scripts/orbit-local.py up
```
脚本创建随机数据库密码和管理员密码,构建镜像,依次完成 MySQL 健康检查、前向迁移、运行账号权限收紧、结构核验、管理员/测试身份初始化及服务启动。成功后还检查 readiness 和实际 SDK 队列权限。
启动状态:
```sh
./scripts/orbit-local.py status
```
`mysql` 和 `orbit` 长期运行;`migrate`、`grant`、`verify`、`archive-init` 显示 `Exited (0)` 是一次性初始化成功。
## 控制台与接入地址
| 用途 | 地址/配置 |
| --- | --- |
| 本机控制台 | <http://127.0.0.1:18080/console/> |
| 本机 API / SDK baseURL | `http://127.0.0.1:18080` |
| Docker 网络 | `orbit-test-services` |
| 同网络容器 API / SDK baseURL | `http://orbit-test:8080` |
| Worker WebSocket | 上述主机的 `/v1/worker/connect`;SDK 自动处理升级与鉴权 |
| 结果通知 | 使用 SDK 持久订阅 + EventConsumer,参见[消费手册](../../docs/sdk/event-consumer.md) |
| 就绪检查 | `/health/ready` |
| 管理员 | `admin` |
| 管理员密码 | 本机 `.local/compose-test/admin.password` |
浏览器直接账号密码登录,自动使用同源地址,不需要上传凭据 JSON。MySQL 仅在环境内部网络开放,业务服务通过 Orbit API 接入。
## 本机业务服务
初始化生成 `.local/compose-test/host.env`,包含:
```text
ORBIT_URL=http://127.0.0.1:18080
ORBIT_TOKEN=自动生成的真实令牌
ORBIT_NAMESPACE_ID=自动生成的命名空间ID
ORBIT_QUEUE_ID=自动生成的队列ID
```
在仓库根目录加载,再启动自己的服务:
```sh
set -a
. ./.local/compose-test/host.env
set +a
# 在当前环境中启动你的业务进程
```
这些变量由业务配置代码读取,再传给 SDK;SDK 本身不自动加载 env 文件:
```go
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{
Token: os.Getenv("ORBIT_TOKEN"),
})
if err != nil { return err }
queueID := os.Getenv("ORBIT_QUEUE_ID")
```
SDK 安装、任务处理器和完整服务见[根 README](../../README.md#sdk-怎么调用)、[HTTP 接入例子](../../examples/http-service/README.md)。业务处理器由接入服务注册运行;平台不会自动执行业务代码。
## 其他 Docker Compose 服务
在业务 Compose 中接入共享网络,并引用本机生成的 `docker.env`。将下方路径换为本机 Orbit 仓库的绝对路径;这份文件含真实令牌,不提交到业务 Git。
```yaml
services:
app:
# 这里保留业务服务自己的 image/build、ports 等配置
env_file:
- /absolute/path/to/orbit/.local/compose-test/docker.env
networks:
- default
- orbit-test
networks:
orbit-test:
external: true
name: orbit-test-services
```
业务服务读取到的 `ORBIT_URL` 为 `http://orbit-test:8080`,HTTP 和 WebSocket 都通过共享网络访问;Docker 内不要把 `127.0.0.1:18080` 当成宿主机地址。先启动 Orbit 环境,再启动业务 Compose。
如果仅临时联调一个已运行容器,可以执行 `docker network connect orbit-test-services <业务容器名>`,并在该业务服务配置中设置上述 URL 和凭据;容器重建后应采用 Compose 声明网络。
## 运行示例与看数据
宿主机有 Go 1.27.1 时,从仓库根目录执行:
```sh
export ORBIT_URL=http://127.0.0.1:18080
export ORBIT_CREDENTIALS_FILE="$PWD/.local/compose-test/sdk.credentials.json"
cd examples
go run ./worker
go run ./schedules
go run ./workflow
go run ./event-protocol
```
Worker 返回三条 `SUCCEEDED`;计划示例实际执行后暂停;流程示例完成并行、信号和审计核对;事件示例验证通知、ACK 与断线重报。刷新控制台即可查看真实数据。短示例每次产生新测试记录,不是长期业务消费者。
## 日常命令与持久化
```sh
./scripts/orbit-local.py logs # 默认显示 Orbit 最近 100 行
./scripts/orbit-local.py logs mysql # 数据库日志
./scripts/orbit-local.py restart # 只重启 Orbit,等到 ready
./scripts/orbit-local.py stop # 停止本环境的 Orbit 和 MySQL
./scripts/orbit-local.py start # 启动已有容器,等到 ready
./scripts/orbit-local.py up # 初次部署/重建镜像与容器;复用原密码和身份
```
数据库卷为 `orbit-test_orbit-mysql`,归档卷为 `orbit-test_orbit-archives`,容器配置 `restart: unless-stopped`。停止、重启、重新构建都保留数据。私密配置目录 `.local/compose-test` 也要保留;仅留下卷而丢失密码文件不能当成全新安装。
归档卷预算 1 GiB,归档计划和保留策略通过控制台配置,默认不自动开启计划;目前导出保留源数据。不要为腾空间删除数据库卷。
默认使用 `10.253.240.0/24` 内部网络和 `10.253.241.0/24` 服务网络,避免占用已耗尽的 Docker 默认子网池。如与本机已有网络冲突,在私密 `compose.env` 中设置 `ORBIT_TEST_BACKEND_SUBNET`、`ORBIT_TEST_SERVICES_SUBNET` 后首次启动;已有网络需要先协调连接服务后再调整。
配置统一从 `.local/compose-test/compose.env` 读取,脚本隔离其他项目的同名环境变量。可查看原始配置:[基础文件](../docker-compose.yml) + [本地覆盖](compose.yml)。脚本不提供删卷命令;需手工管理时务必固定 Compose 项目 `orbit-test` 和同一份配置。
## 凭据和权限
初始 SDK 凭据有效期 30 天,具体 `expires_at` 在私密 `sdk.credentials.json` 和启动输出中。凭据拥有此测试命名空间的任务、Worker、调度、订阅、流程及恢复权限;这是联调身份,正式环境按角色收窄。凭据到期/撤销后不会自动重新初始化或重置历史,按[轮换手册](../../docs/api/authentication.md)处理。
管理员密码、数据库密码、SDK Token 均保存在 `.local/compose-test` 下的私密文件;目录权限 0700、文件权限 0600,Git 和 Docker 构建上下文都排除这些文件。需要其他服务读取时分发 `host.env` / `docker.env`,不要给业务服务数据库管理员配置。

58
deploy/local/compose.yml Normal file
View File

@@ -0,0 +1,58 @@
# 叠加 deploy/docker-compose.yml;使用 scripts/orbit-local.py 固定项目和私密配置。
services:
archive-init:
image: mysql:8.4
restart: "no"
user: "0:0"
entrypoint: ["/bin/sh", "-ec"]
command: ["chown 65532:65532 /archives && chmod 700 /archives"]
volumes:
- orbit-archives:/archives
bootstrap:
profiles: ["tools"]
image: ${ORBIT_IMAGE}
user: ${ORBIT_LOCAL_USER}
entrypoint: ["/usr/local/bin/orbit-admin"]
command: ["verify"]
environment:
ORBIT_MYSQL_DSN: "root:${ORBIT_DB_ROOT_PASSWORD}@tcp(mysql:3306)/${ORBIT_DB_NAME}?parseTime=true&multiStatements=true"
volumes:
- ${ORBIT_LOCAL_DIRECTORY}:/run/local
orbit:
build:
context: ..
dockerfile: Dockerfile
args:
VERSION: ${ORBIT_BUILD_VERSION}
REVISION: ${ORBIT_BUILD_REVISION}
BUILD_TIME: ${ORBIT_BUILD_TIME}
depends_on:
archive-init:
condition: service_completed_successfully
environment:
ORBIT_ARCHIVE_ROOT: /var/lib/orbit/archives
ORBIT_ARCHIVE_STORAGE_ID: ${ORBIT_ARCHIVE_STORAGE_ID}
ORBIT_ARCHIVE_MAX_BYTES: 1073741824
ORBIT_ARCHIVE_TICK_INTERVAL: 10s
volumes:
- orbit-archives:/var/lib/orbit/archives
networks:
default:
services:
aliases: [orbit-test]
networks:
default:
ipam:
config:
- subnet: ${ORBIT_TEST_BACKEND_SUBNET:-10.253.240.0/24}
services:
name: orbit-test-services
ipam:
config:
- subnet: ${ORBIT_TEST_SERVICES_SUBNET:-10.253.241.0/24}
volumes:
orbit-archives:

View File

@@ -266,3 +266,11 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
- 开发工作区、构建、MySQL/控制台/历史 SDK 回归同步迁移路径;SDK 保持独立模块。
- 使用 main 与 v0.1.1 / sdk/go/v0.1.1 / adapters/outbox/mysql/v0.1.1 交付,旧标签不变;业务固定版本,无需开发分支或提交号。
- 标准 7 模块回归、SDK/协议/文档门禁、真实 MySQL 全量、控制台端到端及历史任务 API 双向 smoke 均通过;main 与三组 v0.1.1 标签已推送,仓库外空缓存安装两个模块、构建、HTTP smoke 及校验和验证通过,无 replace;证据见[发布记录](reports/v0.1.1-release.md)。
### 本地 Compose 联调环境(2026-09-27)
- 已提供 `scripts/orbit-local.py` 和本地 Compose 覆盖文件,部署在独立项目 orbit-test,主机端口 18080;MySQL/归档卷持久保存,迁移账号与运行账号分离。
- 初始化单管理员、完整测试身份和 host.env/docker.env,私密内容位于被 Git 与 Docker 构建排除的 .local/compose-test;重复启动复用原身份,不重置密码或数据。
- 提供 orbit-test-services 共享网络与 http://orbit-test:8080 地址,固定可配置子网,解决本机默认 Docker 地址池耗尽的问题。
- 已验证:真实容器构建/迁移/零外键/权限收紧;浏览器登录协议和未登录跳转;另一容器运行 Worker 完成三项任务及事件 ACK 重报;定时/流程示例;重复部署与 Orbit/MySQL 停止再启动后原任务、身份保持。
- 接入、账号位置、启动/停止及网络声明见[本地部署手册](../deploy/local/README.md)。这是本地联调环境,不改变平台或 SDK 协议。

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)