From 98ff028228c043d24cab5e10e9a798ef6ca0124e Mon Sep 17 00:00:00 2001 From: k2safe Date: Sun, 27 Sep 2026 09:35:49 +0800 Subject: [PATCH] feat: add persistent local Compose integration environment --- .dockerignore | 5 ++ README.md | 2 + deploy/local/README.md | 134 ++++++++++++++++++++++++++++ deploy/local/compose.yml | 58 ++++++++++++ docs/08-development-plan.md | 8 ++ scripts/orbit-local.py | 173 ++++++++++++++++++++++++++++++++++++ 6 files changed, 380 insertions(+) create mode 100644 deploy/local/README.md create mode 100644 deploy/local/compose.yml create mode 100755 scripts/orbit-local.py diff --git a/.dockerignore b/.dockerignore index 7bc848d..467f716 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,8 @@ sdk examples deploy *.log +.local +.env +.env.* +!.env.example +**/*.credentials.json diff --git a/README.md b/README.md index 50c8b7f..2eaa286 100644 --- a/README.md +++ b/README.md @@ -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 后,页面自动使用这个域名,无需在界面填写服务地址。 diff --git a/deploy/local/README.md b/deploy/local/README.md new file mode 100644 index 0000000..80d5eeb --- /dev/null +++ b/deploy/local/README.md @@ -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)` 是一次性初始化成功。 + +## 控制台与接入地址 + +| 用途 | 地址/配置 | +| --- | --- | +| 本机控制台 | | +| 本机 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`,不要给业务服务数据库管理员配置。 diff --git a/deploy/local/compose.yml b/deploy/local/compose.yml new file mode 100644 index 0000000..6c08947 --- /dev/null +++ b/deploy/local/compose.yml @@ -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: diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index cbb29d2..55760e9 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -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 协议。 diff --git a/scripts/orbit-local.py b/scripts/orbit-local.py new file mode 100755 index 0000000..612d36f --- /dev/null +++ b/scripts/orbit-local.py @@ -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)