From 9f77d0bee8adfec1f3035efd23296b86c516a2eb Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 22 Jul 2026 10:24:57 +0000 Subject: [PATCH 1/2] fix(compose): make runtime bootstrap verifiable --- .../references/repository-map.md | 32 +- .../references/technology-stack.md | 13 +- .../chatnow-testing/references/framework.md | 12 +- README.md | 20 +- common/infra/s3_client.hpp | 37 +- common/mq/amqp_url.hpp | 43 ++ common/mq/channel.hpp | 17 +- common/mq/rabbitmq.hpp | 1 + common/utils/avatar_url.hpp | 2 +- conf/docker/conversation_server.conf | 2 +- conf/docker/media.json | 32 + conf/docker/message_server.conf | 2 +- conf/docker/push_server.conf | 2 +- conf/docker/transmite_server.conf | 2 +- conf/local/conversation_server.conf | 2 +- conf/local/message_server.conf | 2 +- conf/local/push_server.conf | 2 +- conf/local/transmite_server.conf | 2 +- conf/media.json | 7 +- conf/transmite_server.conf.example | 2 +- conversation/source/conversation_server.cc | 2 +- docker-compose.yml | 309 +++++++--- docker/minio-init/Dockerfile | 8 + docker/minio-init/entrypoint.sh | 79 ++- docs/operations/compose-runtime.md | 115 ++++ docs/operations/runtime-secrets.md | 15 +- entrypoint.sh | 69 ++- gateway/source/gateway_server.h | 30 +- media/source/media_main.cc | 8 +- media/source/media_server.h | 6 +- message/source/message_server.cc | 2 +- message/source/message_server.h | 2 +- push/source/push_server.cc | 2 +- push/source/push_server.h | 2 +- scripts/converge_mysql_users.sh | 86 +++ scripts/init_mysql.sh | 51 ++ scripts/init_rabbitmq.py | 97 +++ scripts/init_redis_cluster.sh | 56 ++ scripts/wait_for_services.sh | 115 ++++ sql/V1__core.sql | 325 ++++++++++ sql/V4__media.sql | 114 ++-- tests/pkg/contracts/compose_runtime_test.go | 582 ++++++++++++++++++ transmite/source/transmite_server.cc | 2 +- transmite/source/transmite_server.h | 2 +- 44 files changed, 2063 insertions(+), 250 deletions(-) create mode 100644 common/mq/amqp_url.hpp create mode 100644 conf/docker/media.json create mode 100644 docker/minio-init/Dockerfile create mode 100644 docs/operations/compose-runtime.md create mode 100755 scripts/converge_mysql_users.sh create mode 100755 scripts/init_mysql.sh create mode 100644 scripts/init_rabbitmq.py create mode 100755 scripts/init_redis_cluster.sh create mode 100755 scripts/wait_for_services.sh create mode 100644 sql/V1__core.sql create mode 100644 tests/pkg/contracts/compose_runtime_test.go diff --git a/.agents/skills/chatnow-orienting/references/repository-map.md b/.agents/skills/chatnow-orienting/references/repository-map.md index 2697878..57a297e 100644 --- a/.agents/skills/chatnow-orienting/references/repository-map.md +++ b/.agents/skills/chatnow-orienting/references/repository-map.md @@ -20,21 +20,21 @@ Verified: 2026-07-22 | `presence/` | Presence aggregation, subscriptions, and typing coordination | `presence/source/presence_server.h`, `presence/source/presence_server.cc`, `proto/presence/presence_service.proto` | | `push/` | WebSocket connections, routes, cross-instance delivery, resend, client ACK ingestion | `push/source/push_server.h`, `push/source/connection.hpp`, `push/source/push_server.cc`, `proto/push/notify.proto` | | `odb/` | ODB entity definitions and durable relational fields | Affected entity, especially `message.hxx`, `user_timeline.hxx`, `conversation_member.hxx`, and `media_*.hxx` | -| `conf/` | Non-secret local/container flags and JSON configuration; tracked files are not a runtime secret source | `conf/local/`, `conf/docker/`, `conf/auth.json`, `conf/media.json` | -| `sql/` | Versioned schema migrations | `sql/V4__media.sql` and any migration matching affected ODB entities | -| `docker/` | Separate MinIO topology and initialization; not wired into the root application network | `docker/docker-compose.yml`, `docker/minio-init/entrypoint.sh` | -| `docker-compose.yml` | Application stack declaration; Media object-storage wiring is incomplete | Root `docker-compose.yml`, then affected `Dockerfile` and `conf/docker` file | -| `scripts/` | Operational support and monitoring configuration | `scripts/install_aws_sdk_linux.sh`, `scripts/prometheus/redis_alerts.yml` | +| `conf/` | Non-secret local/container flags and JSON configuration; tracked files are not a runtime secret source | `conf/local/`, `conf/docker/`, `conf/auth.json`, local `conf/media.json`, container `conf/docker/media.json` | +| `sql/` | Versioned forward-only schema migrations for all current ODB objects | `sql/V1__core.sql`, `sql/V4__media.sql`, `scripts/init_mysql.sh` | +| `docker/` | Reusable MinIO initialization script plus a supplemental standalone topology that is not combined with root Compose | `docker/minio-init/entrypoint.sh`, `docker/docker-compose.yml` | +| `docker-compose.yml` | Integrated local application topology, health conditions, and one-shot convergence services | Root `docker-compose.yml`, affected `Dockerfile`, `conf/docker`, and initializer script | +| `scripts/` | Runtime convergence/readiness, operational support, and monitoring | `scripts/init_mysql.sh`, `scripts/converge_mysql_users.sh`, `scripts/init_redis_cluster.sh`, `scripts/init_rabbitmq.py`, `scripts/wait_for_services.sh`, `scripts/prometheus/redis_alerts.yml` | | `tests/` | Pure-Go L1-L4 plus Redis-focused Reliability framework, clients, fixtures, cleanup, and store verification | `tests/Makefile`, `tests/config.yaml`, affected `tests/bvt`, `tests/func`, `tests/perf`, `tests/reliability`, `tests/pkg` | -| `docs/` | Secondary architecture/API context and canonical operations guidance | `docs/operations/runtime-secrets.md`, affected `docs/api/*.yaml`, then relevant architecture documents | +| `docs/` | Secondary architecture/API context and canonical operations guidance | `docs/operations/compose-runtime.md`, `docs/operations/runtime-secrets.md`, affected `docs/api/*.yaml`, then relevant architecture documents | ## Verified ports and infrastructure endpoints -Application ports come from `conf/local`, `conf/docker`, and root `docker-compose.yml`. MinIO ports come from the separate `docker/docker-compose.yml`; this is not an integrated container endpoint map. +Application and integrated infrastructure ports come from `conf/local`, `conf/docker`, and root `docker-compose.yml`. Published root-profile infrastructure ports bind to loopback. | Owner | Local endpoint/port | Container endpoint/port | Evidence | |---|---:|---:|---| -| Gateway HTTP | `127.0.0.1:9000` | `gateway_server:9000` | `gateway_server.conf` `http_listen_port` | +| Gateway HTTP and readiness | `127.0.0.1:9000`; unauthenticated `GET /health` | `gateway_server:9000` | `gateway_server.conf` `http_listen_port`; `GatewayServer::dependencies_ready` | | Media brpc | `127.0.0.1:10002` | `media_server:10002` | `media_server.conf` | | Identity brpc | `127.0.0.1:10003` | `identity_server:10003` | `identity_server.conf` | | Transmite brpc | `127.0.0.1:10004` | `transmite_server:10004` | `transmite_server.conf` | @@ -47,14 +47,18 @@ Application ports come from `conf/local`, `conf/docker`, and root `docker-compos | etcd | `127.0.0.1:2379` | `etcd:2379` | all service configs | | MySQL | `127.0.0.1:3306` | `mysql:3306` | root Compose | | Redis cluster | `127.0.0.1:6379`, `:6380`-`:6384` | `redis-node1:6379`, `redis-node2:6380` through `redis-node6:6384` | root Compose; service seed flags | -| RabbitMQ | `127.0.0.1:5672` | `rabbitmq:5672` | Transmite, Message, Push configs | +| RabbitMQ | `127.0.0.1:5672` | `rabbitmq:5672` | Transmite, Message, Push host-only `mq_host` configs; builders append `5672` | | Elasticsearch | HTTP `127.0.0.1:9200`; transport `:9300` | `elasticsearch:9200`; transport `:9300` | root Compose; service configs | -| MinIO S3 | Host `127.0.0.1:9000`, conflicting with Gateway | `minio:9000` only inside the separate MinIO Compose network | `conf/media.json`; supplemental Compose | -| MinIO console | Host `127.0.0.1:9001`, conflicting with Push WebSocket | `minio:9001` only inside the separate MinIO Compose network | supplemental Compose | +| MinIO S3 | `127.0.0.1:19000` for local access and presigned URLs | `minio:9000` for Media internal operations | root Compose; `conf/media.json`; `conf/docker/media.json` | +| MinIO console | `127.0.0.1:19001` | `minio:9001` | root Compose | -MySQL service configs set `mysql_port=0`, while root Compose exposes MySQL on `3306` and service entrypoints wait on `mysql:3306`; preserve that distinction when diagnosing driver defaults. Gateway's `websocket_listen_port=0` is not the client WebSocket endpoint; Push owns `ws_port=9001`. +MySQL service configs set `mysql_port=0`, while root Compose exposes MySQL on `3306` and service entrypoints wait on `mysql:3306`; preserve that distinction when diagnosing driver defaults. Gateway's `websocket_listen_port=0` is not the client WebSocket endpoint; Push owns `ws_port=9001`. Gateway `GET /health` returns `200` only when all eight discovered business-service channels are available and returns `503` otherwise; it is not a process-only liveness response. -Root Compose mounts `conf/media.json` into Media, but `s3.endpoint=http://127.0.0.1:9000` addresses the Media container itself. Root Compose has no MinIO service/dependency, while the supplemental MinIO Compose project has no declared shared external network with the root project. Do not present these declarations as a working integrated Media topology or recommend their current commands as a functional Media runtime. Any repair must explicitly reconcile the network, endpoint, dependency, and `9000`/`9001` host-port conflicts, then be verified from the affected containers. +Root Compose mounts `conf/docker/media.json` read-only. Media uses `s3.endpoint=http://minio:9000` for server-side S3 operations and `s3.public_endpoint=http://127.0.0.1:19000` to generate URLs reachable by local host clients. `common/infra/s3_client.hpp` owns separate internal and presign clients. Do not use the loopback public endpoint for a remote deployment without replacing it with a client-reachable address. + +`mysql-init` applies read-only `V*.sql` migrations through `scripts/init_mysql.sh` and a checksum ledger. `V1__core.sql` plus `V4__media.sql` cover all 17 current ODB object tables; `scripts/converge_mysql_users.sh` owns the five table-scoped application identities. Redis, RabbitMQ, and MinIO use their own bounded one-shot initializers. `scripts/wait_for_services.sh` is the cross-stack semantic gate; `entrypoint.sh` is only bounded TCP prerequisite polling. + +Root infrastructure state is bind-mounted under `middle/data`. `docker compose down -v` does not remove that state, and the current runtime PR does not include the separate CI clean-state override or workflow integration. Do not claim a repeatable cold start or passing runtime gate from the source topology or static contracts. ## Runtime credential ownership @@ -64,7 +68,7 @@ Current consumers at the verified commit are: - Conversation, Identity, Media, Message, and Relationship resolve service-specific MySQL password inputs through `common/config/secret_resolver.hpp`. - Transmite, Message, and Push resolve service-specific RabbitMQ password inputs through the same resolver. - Identity resolves its SMTP password; Media resolves separate S3 access-key and secret-key inputs. Non-secret S3 settings remain in `conf/media.json`. -- Root Compose requires MySQL, RabbitMQ, and supplemental MinIO bootstrap values through deployment environment references. These are separate from least-privileged application inputs. Redis has no configured password or ACL consumer. +- Root Compose requires MySQL, RabbitMQ, and MinIO bootstrap values through deployment environment references. One-shot initializers use those bootstrap inputs to converge least-privileged MySQL, RabbitMQ, and MinIO application identities; application containers consume only their own resolver inputs. Redis has no configured password or ACL consumer. Tracked runtime credential literals have been removed from the scoped source, configuration, Compose, and test-runtime surfaces. Do not reintroduce values in documentation, logs, test output, Issues, or PRs. Synthetic test-only credentials and API examples require narrow scanner exemptions rather than broad path allowlists. diff --git a/.agents/skills/chatnow-orienting/references/technology-stack.md b/.agents/skills/chatnow-orienting/references/technology-stack.md index 4232c93..c5b117d 100644 --- a/.agents/skills/chatnow-orienting/references/technology-stack.md +++ b/.agents/skills/chatnow-orienting/references/technology-stack.md @@ -19,7 +19,7 @@ Use this reference for the `3.0-dev` architecture line, then verify task-sensiti | Cache and coordination | Redis 7 Cluster plus local L1 caches | `common/dao/data_redis.hpp`; `common/utils/local_cache.hpp` | | Message broker | RabbitMQ through AMQP-CPP/libev | `common/mq/`; Transmite, Message, and Push source | | Search | Elasticsearch 7 | `common/dao/data_es.hpp`; Message, Identity, Relationship source | -| Object storage | MinIO through the S3-compatible AWS C++ SDK | `common/infra/s3_client.hpp`; Media source; `docker/docker-compose.yml` | +| Object storage | MinIO through the S3-compatible AWS C++ SDK | `common/infra/s3_client.hpp`; Media source; root `docker-compose.yml`; `conf/docker/media.json` | | Service discovery and leases | etcd | `common/infra/etcd.hpp`; service entry files | | Authentication | JWT HS256 with multi-key rotation support | `common/auth/`; `conf/auth.json` | | Logging | spdlog JSON lines with propagated trace context | `common/infra/logger.hpp`; `common/log/`; `gateway/source/gateway_trace.hpp` | @@ -42,18 +42,21 @@ Inspect root `CMakeLists.txt`, the affected service's `CMakeLists.txt`, and its - Local service flags: `conf/local/*_server.conf`. - Container service flags: `conf/docker/*_server.conf`. - JWT keys and TTLs: Identity, Gateway, and Push resolve `CHATNOW_JWT_CONFIG` or `CHATNOW_JWT_CONFIG_FILE` at process startup. The value is the complete JSON document. -- Media S3 application credentials are resolved through the common secret resolver. Buckets, endpoint, presign, and MIME policy remain in `conf/media.json` plus Media flags. +- Media S3 application credentials are resolved through the common secret resolver. `conf/media.json` is the local-process configuration; root Compose mounts `conf/docker/media.json` read-only. Container-internal S3 operations use `http://minio:9000`, while client-facing local presigned URLs use the published `http://127.0.0.1:19000` endpoint. - Example Transmite flags: `conf/transmite_server.conf.example`. - Service defaults and flag definitions: each `/source/_server.cc`. - MySQL passwords for Conversation, Identity, Media, Message, and Relationship use service-specific direct-environment or `_FILE` inputs through `common/config/secret_resolver.hpp`. - RabbitMQ passwords for Transmite, Message, and Push use the same resolver contract. Identity SMTP and Media S3 application credentials are also migrated. +- The `mq_host` flag is host-only for Transmite, Message, and Push. Their builders append the fixed AMQP port `5672`; configuration must not include a port. - The resolver accepts exactly one allowlisted direct environment variable or `_FILE` locator, fails closed on missing/conflicting input, and validates secret-file type, owner, mode, size, and content. It reads once at startup; there is no hot reload. - Bootstrap credentials in Compose remain deployment environment references rather than application resolver inputs. Redis has no configured password or ACL consumer. - The canonical names, consumers, deployment rules, and limitations are maintained in `docs/operations/runtime-secrets.md`. -- Root `docker-compose.yml` declares the application stack used by CI, but it is not a complete integrated Media/MinIO topology: it starts Media without a MinIO service or dependency. -- `docker/docker-compose.yml` separately declares MinIO and its initialization sidecar on a different default Compose network. Media mounts `conf/media.json`, whose `http://127.0.0.1:9000` endpoint resolves to the Media container itself, not to that separate MinIO container. +- Root `docker-compose.yml` is the integrated local application topology. It includes health-checked MySQL, six-node Redis Cluster, RabbitMQ, Elasticsearch, etcd, and MinIO plus one-shot `mysql-init`, `redis-cluster-init`, `rabbitmq-init`, and `minio-init` convergence services. Application dependencies use health or `service_completed_successfully` conditions instead of fixed startup delays. +- `mysql-init` mounts `sql/` read-only, applies ordered `V*.sql` files through a checksum ledger, rejects changes to an applied version, covers the 17 current ODB object tables, and converges five table-scoped MySQL application users. This is a forward-only repository bootstrap mechanism, not a general rollback engine. +- MinIO S3 and console ports are published on loopback `19000` and `19001`; Gateway HTTP remains `9000` and Push WebSocket remains `9001`. The supplemental `docker/docker-compose.yml` is not part of the root topology and must not be combined with it. +- `scripts/wait_for_services.sh` is the bounded semantic cross-stack readiness entry point. It checks Redis Cluster state and slots, the MySQL schema and users, RabbitMQ alarms, Elasticsearch health, MinIO health and buckets, eight exact etcd registrations, dependency-aware Gateway health, and Push listener reachability. The shared service `entrypoint.sh` performs bounded TCP prerequisite polling only. -The two Compose declarations also conflict on host ports: Gateway HTTP and MinIO S3 both publish `9000`; Push WebSocket and the MinIO console both publish `9001`. Therefore, neither `docker compose up -d --build` nor running both Compose files as written proves a functional containerized Media flow. Treat the network, Media S3 endpoint, service dependency, and host-port mapping as unresolved executable contradictions that must be fixed and verified before documenting a working container runtime command. +The source topology and static contracts do not prove a successful clean-slate start. Root infrastructure state uses bind mounts under `middle/data`, so `docker compose down -v` does not remove it. CI workflow integration and isolated clean-state storage remain a separate follow-up PR; until fresh dynamic evidence exists, report full-stack cold start and Go runtime gates as unverified. The canonical operating contract is `docs/operations/compose-runtime.md`. ## Verification entry points diff --git a/.agents/skills/chatnow-testing/references/framework.md b/.agents/skills/chatnow-testing/references/framework.md index c4cea8a..21cba95 100644 --- a/.agents/skills/chatnow-testing/references/framework.md +++ b/.agents/skills/chatnow-testing/references/framework.md @@ -17,7 +17,7 @@ Read this reference before selecting, implementing, running, or reporting a test | L4 Performance | `tests/perf`, `perf` | Throughput and latency baselines | `make -C tests test-perf` | | Reliability | `tests/reliability`, `reliability` | Redis failure injection, recovery, and Push unacked convergence | `make -C tests test-reliability` | -The current `tests/Makefile` also provides `make -C tests proto`, `make -C tests deps`, `make -C tests test-agent-policy`, and `make -C tests clean`. Run `proto` only when generated Go protobuf is required; `clean` removes generated `tests/proto/chatnow` content. Repository policy checks are static evidence and never substitute for a behavior RED or runtime gate. +The current `tests/Makefile` also provides `make -C tests proto`, `make -C tests deps`, `make -C tests test-agent-policy`, and `make -C tests clean`. Run `proto` only when generated Go protobuf is required; `clean` removes generated `tests/proto/chatnow` content. Repository policy checks and `tests/pkg/contracts` Compose/runtime contracts are static evidence and never substitute for a behavior RED or runtime gate. Repository contracts do not consume a BVT/Functional case ID namespace. The Reliability target runs the complete tagged package and does not consume `TEST_RUN`. For an exact test, invoke the same tagged Go package with an anchored `-run` expression, then run `make -C tests test-reliability` for the layer regression. The current fault controller is Redis-only; there is no RabbitMQ, MySQL, arbitrary-service, or general-network controller. @@ -42,6 +42,14 @@ CI runs `build` independently and builds reusable service artifacts. BVT needs ` The dedicated Reliability job exists, but the inspected PR run was skipped after an upstream failure. Its existence is executable-surface evidence, not a successful runtime result. +## Full-stack readiness boundary + +`scripts/wait_for_services.sh` is the bounded pre-suite gate for the root Compose runtime when a full-stack job or operator explicitly invokes it. It verifies Redis Cluster state and slots, all 17 ODB tables and five MySQL application users, RabbitMQ running/alarm state, Elasticsearch yellow-or-green health, MinIO readiness and both media buckets, eight exact etcd service registrations, dependency-aware Gateway `GET /health`, and Push listener reachability. The Push check is TCP reachability, not WebSocket delivery evidence. + +Container health, one-shot initializer completion, and the shared `entrypoint.sh` bounded TCP polling are startup prerequisites; none replaces `scripts/wait_for_services.sh`. Conversely, a passing static contract for the helper or Compose shape does not prove that any container started or that a runtime gate passed. + +The current Issue #78 runtime PR does not own the CI workflow or an isolated clean-state storage override. CI integration remains a separate follow-up PR. Until a fresh run exists for the exact commit, report cold start, BVT, Functional, Reliability, and Performance as `not run` or `blocked`, not passed. + ## Shared framework - `tests/pkg/client`: configuration plus shared HTTP and WebSocket clients. Use `client.NewRequestID()` and `client.NewDeviceID()` for collision-resistant request, idempotency, device, and test-data suffixes. @@ -55,7 +63,7 @@ Use unique IDs for every request and collision-prone resource. Do not rely on a Poll the externally observable condition with a bounded deadline and useful failure message. Suitable conditions include service reachability, a WebSocket event, a database row/state, an Elasticsearch hit, a MinIO object, or an API state transition. A polling interval is allowed; a fixed delay used as proof of readiness or convergence is not. -Assign exactly one owner for each created resource. Prefer suite-level cleanup through `tests/pkg/cleanup`; add `t.Cleanup` for per-test resources such as clients, sockets, temporary objects, or state that suite cleanup cannot safely own. Cleanup must run on assertion failure. CI owns `docker compose down -v` in its full-stack jobs. +Assign exactly one owner for each created resource. Prefer suite-level cleanup through `tests/pkg/cleanup`; add `t.Cleanup` for per-test resources such as clients, sockets, temporary objects, or state that suite cleanup cannot safely own. Cleanup must run on assertion failure. Root Compose persists infrastructure through bind mounts under `middle/data`; `docker compose down -v` does not remove that state. A clean-slate test must use an explicitly disposable storage path or the separate CI override and must never delete a shared tree. ## Change-to-layer matrix diff --git a/README.md b/README.md index 3c82b36..61cd28c 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,21 @@ sudo apt install -y build-essential cmake libprotobuf-dev libbrpc-dev \ sudo bash scripts/install_aws_sdk_linux.sh ``` -### 2. 启动中间件 +### 2. Start the Compose runtime + +The root Compose profile contains the infrastructure and all nine ChatNow processes. Prepare synthetic local secret inputs as described in [Runtime Secret Management](docs/operations/runtime-secrets.md); never use production values in this profile. ```bash -# 一键启动: MySQL + Redis Cluster(6n) + ES + RabbitMQ + etcd + MinIO -docker compose up -d +docker compose config +docker compose up -d --build +./scripts/wait_for_services.sh ``` -### 3. 编译 +The commands above are the implemented interface, but a fresh full-stack cold start and the Go runtime gates have not yet been recorded for this change. Treat the Compose runtime as **Unverified** and consult [Compose Runtime Operations](docs/operations/compose-runtime.md) for topology, readiness, persistence, and evidence boundaries. CI workflow integration remains a separate follow-up PR. + +### 3. Optional native build + +Root Compose already builds and starts the application services. Use the native build only for host-side development: ```bash mkdir build && cd build @@ -60,7 +67,7 @@ cmake .. cmake --build . -j$(nproc) ``` -### 4. 启动服务 +### 4. Optional native service startup ```bash # 例: 以 flagfile 启动某个服务 @@ -174,7 +181,8 @@ ChatNow/ | [设计 Spec](docs/superpowers/specs/) | 20+ 份设计文档,覆盖缓存 / MQ / Proto / 可靠性 | | [实施 Plan](docs/superpowers/plans/) | 15+ 份实施计划,按分支独立 | | [运维 Runbook](docs/operations/) | JWT 轮换 / 日志规范 / 监控 / 烟雾测试 | -| [API 交接](API_HANDOVER.md) | 客户端 SDK 契约 & 错误码 | +| [Compose runtime operations](docs/operations/compose-runtime.md) | Root topology, bootstrap, semantic readiness, persistence, and evidence status | +| [Client retry and error handling](docs/client-sdk/error-retry.md) | Client retry boundaries and error categories | ### 运维 diff --git a/common/infra/s3_client.hpp b/common/infra/s3_client.hpp index 6a749e3..e68d6cf 100644 --- a/common/infra/s3_client.hpp +++ b/common/infra/s3_client.hpp @@ -47,6 +47,7 @@ namespace chatnow { struct S3Options { std::string endpoint; + std::string public_endpoint; std::string region {"us-east-1"}; std::string access_key; std::string secret_key; @@ -56,17 +57,24 @@ struct S3Options { class S3Client { public: explicit S3Client(const S3Options& o) : _opt(o) { - Aws::Client::ClientConfiguration cfg; - cfg.endpointOverride = o.endpoint; - cfg.scheme = (o.endpoint.rfind("https", 0) == 0) - ? Aws::Http::Scheme::HTTPS : Aws::Http::Scheme::HTTP; - cfg.region = o.region; - cfg.verifySSL = false; - _client = std::make_shared( - Aws::Auth::AWSCredentials(o.access_key, o.secret_key), - cfg, - Aws::Client::AWSAuthV4Signer::PayloadSigningPolicy::Never, - o.use_path_style); + auto make_client = [&o](const std::string& endpoint) { + Aws::Client::ClientConfiguration cfg; + cfg.endpointOverride = endpoint; + cfg.scheme = (endpoint.rfind("https", 0) == 0) + ? Aws::Http::Scheme::HTTPS : Aws::Http::Scheme::HTTP; + cfg.region = o.region; + cfg.verifySSL = false; + return std::make_shared( + Aws::Auth::AWSCredentials(o.access_key, o.secret_key), + cfg, + Aws::Client::AWSAuthV4Signer::PayloadSigningPolicy::Never, + o.use_path_style); + }; + + _client = make_client(o.endpoint); + _presign_client = o.public_endpoint.empty() || o.public_endpoint == o.endpoint + ? _client + : make_client(o.public_endpoint); } /* brief: 签发 PUT presigned URL,客户端按 headers 直传 */ @@ -75,7 +83,7 @@ class S3Client { const std::map& headers) const { Aws::Http::HeaderValueCollection h; for (const auto& kv : headers) h.emplace(kv.first, kv.second); - auto url = _client->GeneratePresignedUrlWithSSEC( + auto url = _presign_client->GeneratePresignedUrlWithSSEC( bucket, key, Aws::Http::HttpMethod::HTTP_PUT, h, /*sseKey*/"", seconds); if (url.empty()) throw_failed("presigned_put empty url"); return url; @@ -83,7 +91,7 @@ class S3Client { /* brief: 签发 GET presigned URL(公共/私密资源都用) */ std::string presigned_get(const std::string& bucket, const std::string& key, int seconds) const { - auto url = _client->GeneratePresignedUrl( + auto url = _presign_client->GeneratePresignedUrl( bucket, key, Aws::Http::HttpMethod::HTTP_GET, seconds); if (url.empty()) throw_failed("presigned_get empty url"); return url; @@ -160,7 +168,7 @@ class S3Client { std::string presigned_part(const std::string& bucket, const std::string& key, const std::string& upload_id, int part_number, int seconds) const { - auto url = _client->GeneratePresignedUrl( + auto url = _presign_client->GeneratePresignedUrl( bucket, key, Aws::Http::HttpMethod::HTTP_PUT, seconds); if (url.empty()) throw_failed("presigned_part empty url"); url += "&partNumber=" + std::to_string(part_number) + @@ -241,6 +249,7 @@ class S3Client { S3Options _opt; std::shared_ptr _client; + std::shared_ptr _presign_client; }; } // namespace chatnow diff --git a/common/mq/amqp_url.hpp b/common/mq/amqp_url.hpp new file mode 100644 index 0000000..0a3bbba --- /dev/null +++ b/common/mq/amqp_url.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include + +namespace chatnow { + +inline std::string percent_encode_userinfo(std::string_view value) { + static constexpr char kHex[] = "0123456789ABCDEF"; + std::string encoded; + encoded.reserve(value.size()); + for (const unsigned char byte : value) { + const bool unreserved = + (byte >= 'A' && byte <= 'Z') || + (byte >= 'a' && byte <= 'z') || + (byte >= '0' && byte <= '9') || + byte == '-' || byte == '.' || byte == '_' || byte == '~'; + if (unreserved) { + encoded.push_back(static_cast(byte)); + continue; + } + encoded.push_back('%'); + encoded.push_back(kHex[byte >> 4]); + encoded.push_back(kHex[byte & 0x0F]); + } + return encoded; +} + +inline std::string make_amqp_url(const std::string& user, + const std::string& password, + const std::string& host, + uint16_t port = 5672) { + if (host.empty() || host.find_first_of("@:/?#% \t\r\n") != std::string::npos) { + throw std::invalid_argument("RabbitMQ host must be a hostname without a port"); + } + return "amqp://" + percent_encode_userinfo(user) + ":" + + percent_encode_userinfo(password) + "@" + host + ":" + + std::to_string(port) + "/"; +} + +} // namespace chatnow diff --git a/common/mq/channel.hpp b/common/mq/channel.hpp index 315ad88..ea05640 100644 --- a/common/mq/channel.hpp +++ b/common/mq/channel.hpp @@ -123,6 +123,21 @@ class ServiceManager return it->second->choose(); } + /* brief: Return whether a declared service currently has at least one channel. + * Copy the shared owner while holding the manager lock, then inspect the + * channel after releasing it so readiness checks do not extend the nested + * manager -> channel lock scope used by choose(). */ + bool available(const std::string &service_name) const { + ServiceChannel::ptr service; + { + std::unique_lock lock(_mutex); + auto it = _services.find(service_name); + if(it == _services.end()) return false; + service = it->second; + } + return service->size() != 0; + } + /* brief: 声明关注哪些服务(不关注的服务上下线事件会被忽略,节省内存) */ void declared(const std::string &service_name) { std::unique_lock lock(_mutex); @@ -179,7 +194,7 @@ class ServiceManager return service_instance.substr(0, pos); } - std::mutex _mutex; + mutable std::mutex _mutex; std::unordered_set _follow_services; std::unordered_map _services; }; diff --git a/common/mq/rabbitmq.hpp b/common/mq/rabbitmq.hpp index e195410..7a27603 100644 --- a/common/mq/rabbitmq.hpp +++ b/common/mq/rabbitmq.hpp @@ -29,6 +29,7 @@ #include #include #include "infra/logger.hpp" +#include "mq/amqp_url.hpp" namespace chatnow { diff --git a/common/utils/avatar_url.hpp b/common/utils/avatar_url.hpp index 411ea18..82949ef 100644 --- a/common/utils/avatar_url.hpp +++ b/common/utils/avatar_url.hpp @@ -18,7 +18,7 @@ * * 输入参数: * - public_url_prefix:来自 conf/media.json `media.public_url_prefix`, - * 例如 "http://127.0.0.1:9000/chatnow-media-public" 或 "https://cdn.example.com"。 + * 例如 "http://127.0.0.1:19000/chatnow-media-public" 或 "https://cdn.example.com"。 * - file_id:snowflake 16 hex(见 file/source/upload_handler.hpp)。 */ diff --git a/conf/docker/conversation_server.conf b/conf/docker/conversation_server.conf index 6236537..8ea37d4 100644 --- a/conf/docker/conversation_server.conf +++ b/conf/docker/conversation_server.conf @@ -24,4 +24,4 @@ -mysql_cset=utf8mb4 -mysql_port=0 -mysql_pool_count=4 --public_url_prefix=http://gateway_server:9000/chatnow-media-public +-public_url_prefix=http://127.0.0.1:19000/chatnow-media-public diff --git a/conf/docker/media.json b/conf/docker/media.json new file mode 100644 index 0000000..eab194a --- /dev/null +++ b/conf/docker/media.json @@ -0,0 +1,32 @@ +{ + "_comment": "Container Media configuration. Credentials are injected through the runtime secret resolver.", + + "s3": { + "endpoint": "http://minio:9000", + "public_endpoint": "http://127.0.0.1:19000", + "region": "us-east-1" + }, + + "media": { + "public_bucket": "chatnow-media-public", + "private_bucket": "chatnow-media-private", + "public_url_prefix": "http://127.0.0.1:19000/chatnow-media-public", + "presign_seconds": 900, + "asr_endpoint": "", + "mime_whitelist": [ + {"prefix": "image/jpeg", "max_mb": 20}, + {"prefix": "image/png", "max_mb": 20}, + {"prefix": "image/gif", "max_mb": 20}, + {"prefix": "image/webp", "max_mb": 20}, + {"prefix": "video/mp4", "max_mb": 500}, + {"prefix": "video/webm", "max_mb": 500}, + {"prefix": "video/quicktime", "max_mb": 500}, + {"prefix": "audio/aac", "max_mb": 50}, + {"prefix": "audio/mp4", "max_mb": 50}, + {"prefix": "audio/ogg", "max_mb": 50}, + {"prefix": "audio/webm", "max_mb": 50}, + {"prefix": "application/pdf", "max_mb": 100}, + {"prefix": "text/plain", "max_mb": 5} + ] + } +} diff --git a/conf/docker/message_server.conf b/conf/docker/message_server.conf index 6015034..8d15c6b 100644 --- a/conf/docker/message_server.conf +++ b/conf/docker/message_server.conf @@ -17,7 +17,7 @@ -mysql_port=0 -mysql_pool_count=4 -mq_user=root --mq_host=rabbitmq:5672 +-mq_host=rabbitmq -mq_msg_exchange=chat_msg_exchange -mq_msg_queue_db=msg_queue_db -mq_msg_queue_es=msg_queue_es diff --git a/conf/docker/push_server.conf b/conf/docker/push_server.conf index e8605e9..c782dee 100644 --- a/conf/docker/push_server.conf +++ b/conf/docker/push_server.conf @@ -17,7 +17,7 @@ -redis_keep_alive=true -redis_pool_size=16 -mq_user=root --mq_host=rabbitmq:5672 +-mq_host=rabbitmq -mq_push_exchange=chat_push_exchange -mq_push_queue=msg_push_queue -mq_push_binding_key=push diff --git a/conf/docker/transmite_server.conf b/conf/docker/transmite_server.conf index e1d17de..0abba87 100644 --- a/conf/docker/transmite_server.conf +++ b/conf/docker/transmite_server.conf @@ -18,7 +18,7 @@ -redis_keep_alive=true -redis_pool_size=8 -mq_user=root --mq_host=rabbitmq:5672 +-mq_host=rabbitmq -mq_msg_exchange=chat_msg_exchange -mq_msg_queue= -mq_msg_binding_key= diff --git a/conf/local/conversation_server.conf b/conf/local/conversation_server.conf index 51f0b97..62a7ead 100644 --- a/conf/local/conversation_server.conf +++ b/conf/local/conversation_server.conf @@ -24,4 +24,4 @@ -mysql_cset=utf8mb4 -mysql_port=0 -mysql_pool_count=4 --public_url_prefix=http://127.0.0.1:9000/chatnow-media-public +-public_url_prefix=http://127.0.0.1:19000/chatnow-media-public diff --git a/conf/local/message_server.conf b/conf/local/message_server.conf index f680702..2337a4e 100644 --- a/conf/local/message_server.conf +++ b/conf/local/message_server.conf @@ -17,7 +17,7 @@ -mysql_port=0 -mysql_pool_count=4 -mq_user=root --mq_host=127.0.0.1:5672 +-mq_host=127.0.0.1 -mq_msg_exchange=chat_msg_exchange -mq_msg_queue_db=msg_queue_db -mq_msg_queue_es=msg_queue_es diff --git a/conf/local/push_server.conf b/conf/local/push_server.conf index 10bef71..d145a86 100644 --- a/conf/local/push_server.conf +++ b/conf/local/push_server.conf @@ -17,7 +17,7 @@ -redis_keep_alive=true -redis_pool_size=16 -mq_user=root --mq_host=127.0.0.1:5672 +-mq_host=127.0.0.1 -mq_push_exchange=chat_push_exchange -mq_push_queue=msg_push_queue -mq_push_binding_key=push diff --git a/conf/local/transmite_server.conf b/conf/local/transmite_server.conf index 84b865c..85b7785 100644 --- a/conf/local/transmite_server.conf +++ b/conf/local/transmite_server.conf @@ -18,7 +18,7 @@ -redis_keep_alive=true -redis_pool_size=8 -mq_user=root --mq_host=127.0.0.1:5672 +-mq_host=127.0.0.1 -mq_msg_exchange=chat_msg_exchange -mq_msg_queue= -mq_msg_binding_key= diff --git a/conf/media.json b/conf/media.json index 515fa39..a69ad16 100644 --- a/conf/media.json +++ b/conf/media.json @@ -2,14 +2,15 @@ "_comment": "P4 媒体子系统 JSON 配置:仅 s3 + media 段;mysql/redis/etcd/listen_port 等走 gflags", "s3": { - "endpoint": "http://127.0.0.1:9000", - "region": "us-east-1" + "endpoint": "http://127.0.0.1:19000", + "public_endpoint": "http://127.0.0.1:19000", + "region": "us-east-1" }, "media": { "public_bucket": "chatnow-media-public", "private_bucket": "chatnow-media-private", - "public_url_prefix": "http://127.0.0.1:9000/chatnow-media-public", + "public_url_prefix": "http://127.0.0.1:19000/chatnow-media-public", "presign_seconds": 900, "asr_endpoint": "", "mime_whitelist": [ diff --git a/conf/transmite_server.conf.example b/conf/transmite_server.conf.example index 84b865c..85b7785 100644 --- a/conf/transmite_server.conf.example +++ b/conf/transmite_server.conf.example @@ -18,7 +18,7 @@ -redis_keep_alive=true -redis_pool_size=8 -mq_user=root --mq_host=127.0.0.1:5672 +-mq_host=127.0.0.1 -mq_msg_exchange=chat_msg_exchange -mq_msg_queue= -mq_msg_binding_key= diff --git a/conversation/source/conversation_server.cc b/conversation/source/conversation_server.cc index 7bc4150..d1aa14f 100644 --- a/conversation/source/conversation_server.cc +++ b/conversation/source/conversation_server.cc @@ -34,7 +34,7 @@ DEFINE_string(mysql_cset, "utf8mb4", "MySQL客户端字符集"); DEFINE_int32(mysql_port, 0, "MySQL服务器访问端口"); DEFINE_int32(mysql_pool_count, 4, "MySQL连接池最大连接数量"); -DEFINE_string(public_url_prefix, "http://127.0.0.1:9000/chatnow-media-public", +DEFINE_string(public_url_prefix, "http://127.0.0.1:19000/chatnow-media-public", "Media 公开 bucket URL 前缀(avatar_file_id → URL 转换用)"); int main(int argc, char *argv[]) diff --git a/docker-compose.yml b/docker-compose.yml index a0d2293..9c812d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: etcd: image: quay.io/coreos/etcd:v3.4.30 @@ -12,103 +10,158 @@ services: volumes: - ./middle/data/etcd:/var/lib/etcd:rw ports: - - 2379:2379 + - "127.0.0.1:2379:2379" + healthcheck: + test: ["CMD", "etcdctl", "--endpoints=http://127.0.0.1:2379", "endpoint", "health"] + interval: 2s + timeout: 2s + retries: 30 restart: always mysql: image: mysql:8.0.44 container_name: mysql-service environment: MYSQL_ROOT_PASSWORD: ${CHATNOW_MYSQL_ROOT_PASSWORD:?CHATNOW_MYSQL_ROOT_PASSWORD is required} + MYSQL_DATABASE: chatnow volumes: - - ./sql:/docker-entrypoint-initdb.d/:rw - ./middle/data/mysql:/var/lib/mysql:rw ports: - - 3306:3306 + - "127.0.0.1:3306:3306" + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping --host=127.0.0.1 --silent"] + interval: 2s + timeout: 2s + retries: 60 + start_period: 10s restart: always + mysql-init: + image: mysql:8.0.44 + container_name: chatnow-mysql-init + depends_on: + mysql: + condition: service_healthy + environment: + MYSQL_ROOT_PASSWORD: ${CHATNOW_MYSQL_ROOT_PASSWORD:?CHATNOW_MYSQL_ROOT_PASSWORD is required} + CHATNOW_IDENTITY_MYSQL_PASSWORD: ${CHATNOW_IDENTITY_MYSQL_PASSWORD:?CHATNOW_IDENTITY_MYSQL_PASSWORD is required} + CHATNOW_CONVERSATION_MYSQL_PASSWORD: ${CHATNOW_CONVERSATION_MYSQL_PASSWORD:?CHATNOW_CONVERSATION_MYSQL_PASSWORD is required} + CHATNOW_RELATIONSHIP_MYSQL_PASSWORD: ${CHATNOW_RELATIONSHIP_MYSQL_PASSWORD:?CHATNOW_RELATIONSHIP_MYSQL_PASSWORD is required} + CHATNOW_MESSAGE_MYSQL_PASSWORD: ${CHATNOW_MESSAGE_MYSQL_PASSWORD:?CHATNOW_MESSAGE_MYSQL_PASSWORD is required} + CHATNOW_MEDIA_MYSQL_PASSWORD: ${CHATNOW_MEDIA_MYSQL_PASSWORD:?CHATNOW_MEDIA_MYSQL_PASSWORD is required} + volumes: + - ./sql:/migrations:ro + - ./scripts/init_mysql.sh:/init/init_mysql.sh:ro + - ./scripts/converge_mysql_users.sh:/init/converge_mysql_users.sh:ro + entrypoint: /bin/bash -c "exec /init/init_mysql.sh" + restart: "no" redis-node1: image: redis:7.2.5 container_name: redis-node1 - command: redis-server --port 6379 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --appendonly yes --appendfilename appendonly-1.aof + command: redis-server --port 6379 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --cluster-announce-hostname redis-node1 --cluster-preferred-endpoint-type hostname --appendonly yes --appendfilename appendonly-1.aof volumes: - ./middle/data/redis/node1:/data:rw ports: - - "6379:6379" + - "127.0.0.1:6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "-p", "6379", "ping"] + interval: 2s + timeout: 2s + retries: 30 restart: always redis-node2: image: redis:7.2.5 container_name: redis-node2 - command: redis-server --port 6380 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --appendonly yes --appendfilename appendonly-2.aof + command: redis-server --port 6380 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --cluster-announce-hostname redis-node2 --cluster-preferred-endpoint-type hostname --appendonly yes --appendfilename appendonly-2.aof volumes: - ./middle/data/redis/node2:/data:rw ports: - - "6380:6380" + - "127.0.0.1:6380:6380" + healthcheck: + test: ["CMD", "redis-cli", "-p", "6380", "ping"] + interval: 2s + timeout: 2s + retries: 30 restart: always redis-node3: image: redis:7.2.5 container_name: redis-node3 - command: redis-server --port 6381 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --appendonly yes --appendfilename appendonly-3.aof + command: redis-server --port 6381 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --cluster-announce-hostname redis-node3 --cluster-preferred-endpoint-type hostname --appendonly yes --appendfilename appendonly-3.aof volumes: - ./middle/data/redis/node3:/data:rw ports: - - "6381:6381" + - "127.0.0.1:6381:6381" + healthcheck: + test: ["CMD", "redis-cli", "-p", "6381", "ping"] + interval: 2s + timeout: 2s + retries: 30 restart: always redis-node4: image: redis:7.2.5 container_name: redis-node4 - command: redis-server --port 6382 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --appendonly yes --appendfilename appendonly-4.aof + command: redis-server --port 6382 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --cluster-announce-hostname redis-node4 --cluster-preferred-endpoint-type hostname --appendonly yes --appendfilename appendonly-4.aof volumes: - ./middle/data/redis/node4:/data:rw ports: - - "6382:6382" + - "127.0.0.1:6382:6382" + healthcheck: + test: ["CMD", "redis-cli", "-p", "6382", "ping"] + interval: 2s + timeout: 2s + retries: 30 restart: always redis-node5: image: redis:7.2.5 container_name: redis-node5 - command: redis-server --port 6383 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --appendonly yes --appendfilename appendonly-5.aof + command: redis-server --port 6383 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --cluster-announce-hostname redis-node5 --cluster-preferred-endpoint-type hostname --appendonly yes --appendfilename appendonly-5.aof volumes: - ./middle/data/redis/node5:/data:rw ports: - - "6383:6383" + - "127.0.0.1:6383:6383" + healthcheck: + test: ["CMD", "redis-cli", "-p", "6383", "ping"] + interval: 2s + timeout: 2s + retries: 30 restart: always redis-node6: image: redis:7.2.5 container_name: redis-node6 - command: redis-server --port 6384 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --appendonly yes --appendfilename appendonly-6.aof + command: redis-server --port 6384 --cluster-enabled yes --cluster-config-file /data/nodes.conf --cluster-node-timeout 5000 --cluster-announce-hostname redis-node6 --cluster-preferred-endpoint-type hostname --appendonly yes --appendfilename appendonly-6.aof volumes: - ./middle/data/redis/node6:/data:rw ports: - - "6384:6384" + - "127.0.0.1:6384:6384" + healthcheck: + test: ["CMD", "redis-cli", "-p", "6384", "ping"] + interval: 2s + timeout: 2s + retries: 30 restart: always redis-cluster-init: image: redis:7.2.5 container_name: redis-cluster-init depends_on: - - redis-node1 - - redis-node2 - - redis-node3 - - redis-node4 - - redis-node5 - - redis-node6 - entrypoint: | - /bin/sh -c " - echo 'Waiting for all Redis nodes...' && - sleep 10 && - echo 'Creating 3-master 3-slave cluster...' && - echo yes | redis-cli --cluster create \ - redis-node1:6379 redis-node2:6380 redis-node3:6381 \ - redis-node4:6382 redis-node5:6383 redis-node6:6384 \ - --cluster-replicas 1 && - echo 'Verifying cluster...' && - redis-cli --cluster check redis-node1:6379 && - echo 'Cluster ready.' && - tail -f /dev/null - " + redis-node1: + condition: service_healthy + redis-node2: + condition: service_healthy + redis-node3: + condition: service_healthy + redis-node4: + condition: service_healthy + redis-node5: + condition: service_healthy + redis-node6: + condition: service_healthy + volumes: + - ./scripts/init_redis_cluster.sh:/init/init_redis_cluster.sh:ro + entrypoint: /bin/sh -c "REDIS_INIT_MAX_ATTEMPTS=60 exec /init/init_redis_cluster.sh" restart: "no" elasticsearch: image: elasticsearch:7.17.21 @@ -118,20 +171,84 @@ services: volumes: - ./middle/data/elasticsearch:/var/lib/elasticsearch:rw ports: - - 9200:9200 - - 9300:9300 + - "127.0.0.1:9200:9200" + - "127.0.0.1:9300:9300" + healthcheck: + test: ["CMD-SHELL", "curl --fail --silent 'http://127.0.0.1:9200/_cluster/health?wait_for_status=yellow&timeout=1s' >/dev/null"] + interval: 3s + timeout: 2s + retries: 40 + start_period: 10s restart: always rabbitmq: - image: rabbitmq:3.12.1 + image: rabbitmq:3.12.1-management container_name: rabbitmq-service + hostname: rabbitmq environment: - RABBITMQ_DEFAULT_USER: root + RABBITMQ_NODENAME: rabbit@rabbitmq + RABBITMQ_DEFAULT_USER: ${CHATNOW_RABBITMQ_BOOTSTRAP_USER:-chatnow_bootstrap} RABBITMQ_DEFAULT_PASS: ${CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD:?CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD is required} volumes: - ./middle/data/rabbitmq:/var/lib/rabbitmq:rw ports: - - 5672:5672 + - "127.0.0.1:5672:5672" + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"] + interval: 3s + timeout: 3s + retries: 40 + start_period: 10s restart: always + rabbitmq-init: + image: rabbitmq:3.12.1-management + container_name: rabbitmq-init + depends_on: + rabbitmq: + condition: service_healthy + environment: + RABBITMQ_INIT_MAX_ATTEMPTS: 60 + RABBITMQ_BOOTSTRAP_USER: ${CHATNOW_RABBITMQ_BOOTSTRAP_USER:-chatnow_bootstrap} + RABBITMQ_BOOTSTRAP_PASSWORD: ${CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD:?CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD is required} + CHATNOW_TRANSMITE_MQ_PASSWORD: ${CHATNOW_TRANSMITE_MQ_PASSWORD:?CHATNOW_TRANSMITE_MQ_PASSWORD is required} + CHATNOW_MESSAGE_MQ_PASSWORD: ${CHATNOW_MESSAGE_MQ_PASSWORD:?CHATNOW_MESSAGE_MQ_PASSWORD is required} + CHATNOW_PUSH_MQ_PASSWORD: ${CHATNOW_PUSH_MQ_PASSWORD:?CHATNOW_PUSH_MQ_PASSWORD is required} + volumes: + - ./scripts/init_rabbitmq.py:/init/init_rabbitmq.py:ro + entrypoint: python3 /init/init_rabbitmq.py + restart: "no" + minio: + image: minio/minio:RELEASE.2024-10-13T13-34-11Z + container_name: chatnow-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${CHATNOW_MINIO_ROOT_USER:?CHATNOW_MINIO_ROOT_USER is required} + MINIO_ROOT_PASSWORD: ${CHATNOW_MINIO_ROOT_PASSWORD:?CHATNOW_MINIO_ROOT_PASSWORD is required} + volumes: + - ./middle/data/minio:/data:rw + ports: + - "127.0.0.1:19000:9000" + - "127.0.0.1:19001:9001" + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9000/minio/health/live"] + interval: 3s + timeout: 2s + retries: 40 + start_period: 5s + restart: always + minio-init: + build: docker/minio-init + image: chatnow-minio-init:local + container_name: chatnow-minio-init + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${CHATNOW_MINIO_ROOT_USER:?CHATNOW_MINIO_ROOT_USER is required} + MINIO_ROOT_PASSWORD: ${CHATNOW_MINIO_ROOT_PASSWORD:?CHATNOW_MINIO_ROOT_PASSWORD is required} + MINIO_APP_ACCESS_KEY: ${CHATNOW_MEDIA_S3_ACCESS_KEY:?CHATNOW_MEDIA_S3_ACCESS_KEY is required} + MINIO_APP_SECRET_KEY: ${CHATNOW_MEDIA_S3_SECRET_KEY:?CHATNOW_MEDIA_S3_SECRET_KEY is required} + MINIO_INIT_MAX_ATTEMPTS: 60 + restart: "no" media_server: build: ./media container_name: media_server-service @@ -140,8 +257,8 @@ services: CHATNOW_MEDIA_S3_ACCESS_KEY: ${CHATNOW_MEDIA_S3_ACCESS_KEY:?CHATNOW_MEDIA_S3_ACCESS_KEY is required} CHATNOW_MEDIA_S3_SECRET_KEY: ${CHATNOW_MEDIA_S3_SECRET_KEY:?CHATNOW_MEDIA_S3_SECRET_KEY is required} volumes: - - ./conf/docker/media_server.conf:/im/conf/media_server.conf - - ./conf/media.json:/im/conf/media.json + - ./conf/docker/media_server.conf:/im/conf/media_server.conf:ro + - ./conf/docker/media.json:/im/conf/media.json:ro - ./middle/data/logs:/var/lib/logs:rw - ./middle/data/data:/var/lib/data:rw - ./entrypoint.sh:/im/bin/entrypoint.sh @@ -149,11 +266,16 @@ services: - 10002:10002 restart: always depends_on: - - etcd - - mysql - - redis-cluster-init + etcd: + condition: service_healthy + mysql-init: + condition: service_completed_successfully + redis-cluster-init: + condition: service_completed_successfully + minio-init: + condition: service_completed_successfully entrypoint: - /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384 -c "/im/bin/media_server -flagfile=/im/conf/media_server.conf -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" + /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,minio:9000 -c "/im/bin/media_server -flagfile=/im/conf/media_server.conf -mysql_user=chatnow_media -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" relationship_server: build: ./relationship container_name: relationship_server-service @@ -168,11 +290,14 @@ services: - 10006:10006 restart: always depends_on: - - etcd - - mysql - - elasticsearch + etcd: + condition: service_healthy + mysql-init: + condition: service_completed_successfully + elasticsearch: + condition: service_healthy entrypoint: - /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,elasticsearch:9200 -c "/im/bin/relationship_server -flagfile=/im/conf/relationship_server.conf" + /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,elasticsearch:9200 -c "/im/bin/relationship_server -flagfile=/im/conf/relationship_server.conf -mysql_user=chatnow_relationship" conversation_server: build: ./conversation container_name: conversation_server-service @@ -187,12 +312,16 @@ services: - 10007:10007 restart: always depends_on: - - etcd - - mysql - - redis-cluster-init - - elasticsearch + etcd: + condition: service_healthy + mysql-init: + condition: service_completed_successfully + redis-cluster-init: + condition: service_completed_successfully + elasticsearch: + condition: service_healthy entrypoint: - /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,elasticsearch:9200 -c "/im/bin/conversation_server -flagfile=/im/conf/conversation_server.conf -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" + /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,elasticsearch:9200 -c "/im/bin/conversation_server -flagfile=/im/conf/conversation_server.conf -mysql_user=chatnow_conversation -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" gateway_server: build: ./gateway container_name: gateway_server-service @@ -207,8 +336,10 @@ services: - 9000:9000 restart: always depends_on: - - etcd - - redis-cluster-init + etcd: + condition: service_healthy + redis-cluster-init: + condition: service_completed_successfully entrypoint: /im/bin/entrypoint.sh -d etcd:2379,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384 -c "/im/bin/gateway_server -flagfile=/im/conf/gateway_server.conf -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" push_server: @@ -227,11 +358,14 @@ services: - 9001:9001 restart: always depends_on: - - etcd - - redis-cluster-init - - rabbitmq + etcd: + condition: service_healthy + redis-cluster-init: + condition: service_completed_successfully + rabbitmq-init: + condition: service_completed_successfully entrypoint: - /im/bin/entrypoint.sh -d etcd:2379,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,rabbitmq:5672 -c "/im/bin/push_server -flagfile=/im/conf/push_server.conf -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" + /im/bin/entrypoint.sh -d etcd:2379,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,rabbitmq:5672 -c "/im/bin/push_server -flagfile=/im/conf/push_server.conf -mq_user=chatnow_push -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" message_server: build: ./message container_name: message_server-service @@ -247,13 +381,18 @@ services: - 10005:10005 restart: always depends_on: - - etcd - - mysql - - elasticsearch - - rabbitmq - - redis-cluster-init + etcd: + condition: service_healthy + mysql-init: + condition: service_completed_successfully + elasticsearch: + condition: service_healthy + rabbitmq-init: + condition: service_completed_successfully + redis-cluster-init: + condition: service_completed_successfully entrypoint: - /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,elasticsearch:9200,rabbitmq:5672 -c "/im/bin/message_server -flagfile=/im/conf/message_server.conf -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" + /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,elasticsearch:9200,rabbitmq:5672 -c "/im/bin/message_server -flagfile=/im/conf/message_server.conf -mysql_user=chatnow_message -mq_user=chatnow_message -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" transmite_server: build: ./transmite container_name: transmite_server-service @@ -268,12 +407,14 @@ services: - 10004:10004 restart: always depends_on: - - etcd - - mysql - - rabbitmq - - redis-cluster-init + etcd: + condition: service_healthy + rabbitmq-init: + condition: service_completed_successfully + redis-cluster-init: + condition: service_completed_successfully entrypoint: - /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,rabbitmq:5672 -c "/im/bin/transmite_server -flagfile=/im/conf/transmite_server.conf -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384 -rate_limit_user_max=${TRANSMITE_RATE_LIMIT_USER_MAX:-600} -rate_limit_session_max=${TRANSMITE_RATE_LIMIT_SESSION_MAX:-3000}" + /im/bin/entrypoint.sh -d etcd:2379,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,rabbitmq:5672 -c "/im/bin/transmite_server -flagfile=/im/conf/transmite_server.conf -mq_user=chatnow_transmite -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384 -rate_limit_user_max=${TRANSMITE_RATE_LIMIT_USER_MAX:-600} -rate_limit_session_max=${TRANSMITE_RATE_LIMIT_SESSION_MAX:-3000}" identity_server: build: ./identity container_name: identity_server-service @@ -290,12 +431,16 @@ services: - 10003:10003 restart: always depends_on: - - etcd - - mysql - - redis-cluster-init - - elasticsearch + etcd: + condition: service_healthy + mysql-init: + condition: service_completed_successfully + redis-cluster-init: + condition: service_completed_successfully + elasticsearch: + condition: service_healthy entrypoint: - /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,elasticsearch:9200 -c "/im/bin/identity_server -flagfile=/im/conf/identity_server.conf -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" + /im/bin/entrypoint.sh -d etcd:2379,mysql:3306,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384,elasticsearch:9200 -c "/im/bin/identity_server -flagfile=/im/conf/identity_server.conf -mysql_user=chatnow_identity -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" presence_server: build: ./presence container_name: presence_server-service @@ -308,7 +453,9 @@ services: - 9050:9050 restart: always depends_on: - - etcd - - redis-cluster-init + etcd: + condition: service_healthy + redis-cluster-init: + condition: service_completed_successfully entrypoint: /im/bin/entrypoint.sh -d etcd:2379,redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384 -c "/im/bin/presence_server -flagfile=/im/conf/presence_server.conf -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384" diff --git a/docker/minio-init/Dockerfile b/docker/minio-init/Dockerfile new file mode 100644 index 0000000..c814d3a --- /dev/null +++ b/docker/minio-init/Dockerfile @@ -0,0 +1,8 @@ +FROM minio/mc:RELEASE.2024-10-08T09-37-26Z AS mc + +FROM busybox:1.36.1-musl + +COPY --from=mc /usr/bin/mc /usr/bin/mc +COPY entrypoint.sh /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["/bin/sh", "/usr/local/bin/entrypoint.sh"] diff --git a/docker/minio-init/entrypoint.sh b/docker/minio-init/entrypoint.sh index b408a74..a1ce26b 100755 --- a/docker/minio-init/entrypoint.sh +++ b/docker/minio-init/entrypoint.sh @@ -5,20 +5,79 @@ # chatnow-media-public :公共可读(avatar/sticker),anonymous download # chatnow-media-private :会话媒体,关闭匿名(默认) # --------------------------------------------------------------------------- -# alias "local" 已通过 MC_HOST_local 环境变量预置(见 docker-compose.yml)。 # --------------------------------------------------------------------------- -set -e +set -eu -# 1) 创建 bucket(幂等:-p 让父级路径存在;存在则不报错) -mc mb -p local/chatnow-media-public || true -mc mb -p local/chatnow-media-private || true +MAX_ATTEMPTS=${MINIO_INIT_MAX_ATTEMPTS:-60} +required="MINIO_ROOT_USER MINIO_ROOT_PASSWORD MINIO_APP_ACCESS_KEY MINIO_APP_SECRET_KEY" +for name in $required; do + value=$(printenv "$name" 2>/dev/null || true) + if [ -z "$value" ]; then + echo "minio-init: required credential is missing: ${name}" >&2 + exit 1 + fi +done -# 2) 公共 bucket:任意 GET 都允许(download policy) -mc anonymous set download local/chatnow-media-public +MC_CONFIG_DIR=$(mktemp -d) +export MC_CONFIG_DIR +policy_file="" +cleanup() { + if [ -n "$policy_file" ]; then + rm -f "$policy_file" + fi + rm -rf "$MC_CONFIG_DIR" +} +trap cleanup EXIT + +printf '%s\n%s\n' "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" | + mc alias set local http://minio:9000 --api S3v4 --path on >/dev/null + +attempt=1 +while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if mc admin info local >/dev/null 2>&1; then + break + fi + attempt=$((attempt + 1)) + sleep 1 +done + +if [ "$attempt" -gt "$MAX_ATTEMPTS" ]; then + echo "minio-init: server did not become ready" >&2 + exit 1 +fi -# 3) 私密 bucket:显式声明无匿名(默认即如此,但显式声明便于自检 / 防误改) +mc mb --ignore-existing local/chatnow-media-public +mc mb --ignore-existing local/chatnow-media-private +mc anonymous set download local/chatnow-media-public mc anonymous set none local/chatnow-media-private -echo "[minio-init] buckets ready:" -mc ls local +policy_file=$(mktemp) +cat >"$policy_file" <<'JSON' +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetBucketLocation", "s3:ListBucketMultipartUploads"], + "Resource": ["arn:aws:s3:::chatnow-media-public", "arn:aws:s3:::chatnow-media-private"] + }, + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload"], + "Resource": ["arn:aws:s3:::chatnow-media-public/*", "arn:aws:s3:::chatnow-media-private/*"] + } + ] +} +JSON + +# `user add` and `policy create` converge existing entries to the supplied state. +printf '%s\n%s\n' "$MINIO_APP_ACCESS_KEY" "$MINIO_APP_SECRET_KEY" | + mc admin user add local +mc admin policy create local chatnow-media-app "$policy_file" +mc admin policy attach local chatnow-media-app --user "$MINIO_APP_ACCESS_KEY" + +mc stat local/chatnow-media-public >/dev/null +mc stat local/chatnow-media-private >/dev/null +mc admin user info local "$MINIO_APP_ACCESS_KEY" >/dev/null +echo "minio-init: buckets and application identity are ready" diff --git a/docs/operations/compose-runtime.md b/docs/operations/compose-runtime.md new file mode 100644 index 0000000..9765ab2 --- /dev/null +++ b/docs/operations/compose-runtime.md @@ -0,0 +1,115 @@ +# Compose Runtime Operations + +Target version: `3.0-dev` +Status: Unverified +Reviewed: 2026-07-22 + +This is the canonical operating contract for the repository-root Compose runtime. The source and static contracts implement the topology described below, but a fresh full-stack cold start and the BVT, Functional, Reliability, and Performance gates have not been observed for this change. Do not report those dynamic gates as passing until fresh evidence exists for the exact commit. + +The root profile is a local-development runtime intended for disposable environments. It is not the production HA topology tracked by Issue #73. CI workflow integration and isolated clean-state storage are intentionally deferred to a separate PR. + +## Inputs and scope + +The root `docker-compose.yml` requires synthetic local values for the bootstrap and application secret names it references. Supply them through an ignored local environment file or another approved runtime source. Never commit values or copy production credentials into this profile. The authoritative variable inventory and handling rules are in [Runtime Secret Management](runtime-secrets.md). + +The root profile owns the integrated application topology. Do not combine it with `docker/docker-compose.yml`; that supplemental file is not part of the root runtime contract. + +## Startup and convergence + +The intended operator sequence is: + +```bash +docker compose config +docker compose up -d --build +./scripts/wait_for_services.sh +``` + +These commands describe the implemented interface, not a recorded successful cold start. `docker compose config` validates interpolation and topology only. `scripts/wait_for_services.sh` is the cross-stack readiness gate; container creation or a successful TCP connection alone is insufficient. + +The root topology uses bounded health checks and one-shot convergence services: + +- `mysql-init` waits for MySQL health, runs the checksum-tracked SQL migrator, converges application users and grants, then exits. Application services that use MySQL wait for `service_completed_successfully`. +- `redis-cluster-init` waits for all six Redis nodes, creates the three-master/three-replica cluster only when no healthy cluster metadata exists, verifies all 16,384 slots, then exits. It refuses to recreate degraded existing metadata. Every node advertises its stable Compose service hostname so persisted cluster metadata does not retain ephemeral container IPs. +- `rabbitmq-init` waits for RabbitMQ health, converges the Transmite, Message, and Push application identities and their scoped permissions through the Management API, then exits. RabbitMQ uses a fixed hostname and node name so its persisted Mnesia path remains stable. +- `minio-init` waits for MinIO health, converges the public and private media buckets, anonymous-access policies, and the Media application identity, then exits. +- Application containers depend on the relevant infrastructure health checks and one-shot initializers. Their shared `entrypoint.sh` performs bounded TCP dependency polling; it is not the semantic full-stack readiness gate. + +Every initializer fails on missing required inputs and must remain non-resident. The Redis, RabbitMQ, and MinIO initializers also fail when their bounded attempt limits are exhausted. Fixed startup sleeps and `tail -f` sentinels are not readiness mechanisms. + +## MySQL bootstrap and migrations + +`mysql-init` mounts `sql/` read-only and executes `scripts/init_mysql.sh`. The runner creates `chatnow.schema_migrations`, computes a SHA-256 checksum for every ordered `V*.sql` file, skips an already-applied version only when its checksum matches, and fails closed if an applied migration was changed. New schema changes require a new versioned file; never edit an applied migration. + +The current ODB object set contains 17 tables: + +```text +conversation +conversation_member +friend_apply +media_blob_ref +media_file +media_user_quota +message +message_attachment +message_mention +message_pin +message_reaction +message_read +relation +user +user_block +user_device +user_timeline +``` + +`sql/V1__core.sql` owns the 14 non-media tables and `sql/V4__media.sql` owns the three media tables. Their table creation is idempotent. After schema convergence, `scripts/converge_mysql_users.sh` creates or updates five application identities (`chatnow_identity`, `chatnow_conversation`, `chatnow_relationship`, `chatnow_message`, and `chatnow_media`) and reapplies their table-scoped grants. + +This is a small forward-only repository migrator, not a general rollback engine. MySQL DDL is not treated as transactional across a whole migration and the migration record insertion. Preserve a database backup before operating on non-disposable data, stop on checksum mismatch or partial DDL, and resolve the state explicitly rather than deleting the migration ledger. + +## Media and MinIO endpoints + +Media has two endpoint roles: + +| Role | Local process configuration | Root Compose container configuration | Owner | +|---|---|---|---| +| Internal S3 operations | `http://127.0.0.1:19000` | `http://minio:9000` | Media process to MinIO | +| Client-facing presigned URLs | `http://127.0.0.1:19000` | `http://127.0.0.1:19000` | Test or local client to published MinIO S3 port | + +`conf/media.json` is the local-process configuration. Root Compose mounts `conf/docker/media.json` read-only into Media. `S3Client` uses the internal endpoint for bucket and object operations and a separate presign client for generated URLs. The public endpoint must be reachable by the actual client; the loopback value is valid only for clients running on the Compose host and must be replaced by deployment-specific configuration elsewhere. + +Root Compose publishes MinIO S3 on `127.0.0.1:19000` and its console on `127.0.0.1:19001`, leaving Gateway HTTP on `9000` and Push WebSocket on `9001`. + +## RabbitMQ host contract + +`mq_host` is a host name or address only. Transmite, Message, and Push builders append the fixed AMQP port `5672` when constructing the URL and percent-encode userinfo credentials. Container configuration therefore uses `rabbitmq`; local configuration uses `127.0.0.1`. Supplying `host:5672` produces an invalid double-port URL and is rejected. + +## Semantic readiness + +`scripts/wait_for_services.sh` uses one bounded deadline and polls the following observable conditions: + +- Redis reports `cluster_state:ok`, all 16,384 slots assigned and healthy, and exactly six known nodes. +- MySQL contains all 17 ODB tables and the five required application users. +- RabbitMQ is running without local alarms and all three scoped application identities have permissions. +- Elasticsearch reaches yellow or green cluster health. +- MinIO reports ready and both required buckets are addressable. +- etcd contains exactly the eight expected service registration keys for Identity, Media, Transmite, Message, Relationship, Conversation, Presence, and Push. +- unauthenticated Gateway `GET /health` returns success. +- the Push WebSocket listener is reachable on host port `9001`. + +Gateway `/health` is dependency-aware, not a process-liveness response. It returns HTTP `200` only when the Gateway service manager currently has at least one discovered channel for each of the eight business services; it returns HTTP `503` otherwise. It does not replace the stateful infrastructure probes above. + +The Push check is currently bounded TCP reachability because Push has no separate semantic health endpoint. Do not describe that individual probe as end-to-end WebSocket delivery evidence. + +## Shutdown and clean-state boundary + +Stop the profile with: + +```bash +docker compose down +``` + +The root profile persists infrastructure under `middle/data` through bind mounts. `docker compose down -v` does not remove bind-mounted state and therefore does not prove a clean-slate restart. Do not delete a shared `middle/data` tree or claim repeatable cold-start behavior without an explicitly disposable path, the separate CI storage override, and fresh dynamic evidence. + +## Evidence required before a readiness claim + +Static Compose contracts, shell syntax, formatting, compilation, and `docker compose config` are supporting checks only. A full runtime claim requires fresh evidence for the exact commit from an empty disposable state, successful `scripts/wait_for_services.sh`, and the applicable Go gates. Until the follow-up CI PR supplies that evidence, report the runtime as `Unverified` and list every dynamic gate as not run or blocked. diff --git a/docs/operations/runtime-secrets.md b/docs/operations/runtime-secrets.md index 49f69f6..2a99489 100644 --- a/docs/operations/runtime-secrets.md +++ b/docs/operations/runtime-secrets.md @@ -17,8 +17,8 @@ The following behavior is implemented at the verified commit: | RabbitMQ password | Transmite, Message, and Push | Service-specific direct or `_FILE` input through the common resolver | | SMTP password | Identity | `CHATNOW_IDENTITY_SMTP_PASSWORD` or its `_FILE` companion | | S3 application access and secret keys | Media | Separate service-specific direct or `_FILE` inputs; non-secret S3 settings remain in `conf/media.json` | -| MinIO bootstrap credential | Supplemental MinIO deployment | Required Compose deployment environment references | -| MySQL root and RabbitMQ bootstrap passwords | Root Compose infrastructure | Required Compose deployment environment references | +| MinIO bootstrap credential | Root Compose `minio` and `minio-init` services | Required Compose deployment environment references | +| MySQL root and RabbitMQ bootstrap passwords | Root Compose infrastructure and one-shot initializers | Required Compose deployment environment references | Redis has no configured password or ACL consumer. The ASR helper can accept credentials, but Media startup does not currently wire them. The CI workflow does not pull real credentials; local and CI stack inputs must be synthetic. @@ -41,7 +41,15 @@ Tracked runtime literals have been removed from the scoped source, configuration | Media S3 access key | `CHATNOW_MEDIA_S3_ACCESS_KEY` | `CHATNOW_MEDIA_S3_ACCESS_KEY_FILE` | | Media S3 secret key | `CHATNOW_MEDIA_S3_SECRET_KEY` | `CHATNOW_MEDIA_S3_SECRET_KEY_FILE` | -Compose bootstrap variables (`CHATNOW_MYSQL_ROOT_PASSWORD`, `CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD`, `CHATNOW_MINIO_ROOT_USER`, and `CHATNOW_MINIO_ROOT_PASSWORD`) are required deployment inputs, not common-resolver inputs. Do not append `_FILE` and assume Compose supports it. +Compose bootstrap variables (`CHATNOW_MYSQL_ROOT_PASSWORD`, `CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD`, `CHATNOW_MINIO_ROOT_USER`, and `CHATNOW_MINIO_ROOT_PASSWORD`) are required deployment inputs, not common-resolver inputs. `CHATNOW_RABBITMQ_BOOTSTRAP_USER` may override the synthetic local bootstrap user name. Do not append `_FILE` and assume Compose supports it. + +The root Compose one-shot initializers receive only the credentials they need to converge disposable local application identities: + +- `mysql-init` uses the MySQL root credential plus the five service-specific MySQL passwords to create or update table-scoped users and grants. +- `rabbitmq-init` uses the RabbitMQ bootstrap credential plus the Transmite, Message, and Push passwords to create or update scoped users and permissions. +- `minio-init` uses the MinIO root credential plus the Media S3 application credentials to create or update the two buckets, policies, and application identity. + +RabbitMQ secrets are sent in Management API JSON bodies rather than command arguments. MinIO feeds root and application secret material to `mc` through standard input, isolates `mc` state in a temporary configuration directory, and removes it on exit. These initialization boundaries do not make bootstrap credentials application inputs. Application containers continue to receive only their own direct or `_FILE` resolver inputs. See [Compose Runtime Operations](compose-runtime.md) for ordering and readiness; neither document is evidence that a cold start has passed. ## Current injection contract @@ -76,6 +84,7 @@ Never reuse a bootstrap credential as an application credential. Never mount a f - Use unique synthetic values generated for the disposable environment. They must not be copied from staging or production and must carry no external privilege. - Inject them at runtime through ignored local environment files or ephemeral secret mounts. `.env` being ignored does not make it an approved production store. - CI must source synthetic values from ephemeral job setup or the CI secret mechanism, mask values, avoid command tracing, and tear down volumes and temporary files on every exit path. +- The current runtime PR does not implement that CI workflow integration. Do not infer CI coverage or a clean-slate pass from the root Compose source. - Scanner exemptions must match exact synthetic fixtures or documented API examples. Do not exempt an entire `conf/`, `tests/`, `docs/`, Compose, or source subtree. - Tests may assert source selection and error categories, but must not print the resolved value or any derivative. diff --git a/entrypoint.sh b/entrypoint.sh index eac2d0b..1dc33ff 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,38 +1,41 @@ -#!/bin/bash -# 端口检测函数:等待指定 host:port 可达 -wait_for() { - local host=$1 - local port=$2 - while ! nc -z $host $port - do - echo "$host:$port 端口连接失败,休眠等待"; - sleep 1; - done - echo "$host:$port 检测成功"; -} +#!/usr/bin/env bash -# 解析参数 -declare deps -declare command -while getopts "d:c:" arg -do - case $arg in - d) - deps=$OPTARG;; - c) - command=$OPTARG;; - esac -done +set -euo pipefail + +DEPENDENCY_WAIT_TIMEOUT_SEC=${DEPENDENCY_WAIT_TIMEOUT_SEC:-120} +deps="" +command="" -# 对每个 host:port 对进行端口检测 -for dep in ${deps//,/ } -do - host=${dep%:*} - port=${dep#*:} - wait_for $host $port +while getopts "d:c:" arg; do + case "$arg" in + d) deps=$OPTARG ;; + c) command=$OPTARG ;; + *) exit 2 ;; + esac done -echo "端口检测完毕" +if [[ -z "$command" ]]; then + echo "entrypoint: service command is required" >&2 + exit 2 +fi + +deadline=$((SECONDS + DEPENDENCY_WAIT_TIMEOUT_SEC)) +for dependency in ${deps//,/ }; do + host=${dependency%:*} + port=${dependency##*:} + if [[ -z "$host" || -z "$port" || "$host" == "$dependency" ]]; then + echo "entrypoint: invalid dependency locator" >&2 + exit 2 + fi + + until nc -z -w 1 "$host" "$port"; do + if (( SECONDS >= deadline )); then + echo "entrypoint: dependency did not become reachable: ${host}:${port}" >&2 + exit 1 + fi + sleep 1 + done +done -# 执行命令 -eval $command +echo "entrypoint: dependencies are reachable" +exec /bin/bash -c "$command" diff --git a/gateway/source/gateway_server.h b/gateway/source/gateway_server.h index 077e01b..f72ccad 100644 --- a/gateway/source/gateway_server.h +++ b/gateway/source/gateway_server.h @@ -57,6 +57,7 @@ class GatewayServer { GatewayServer(int http_port, const ServiceManager::ptr &channels, + const Discovery::ptr &discovery, const std::shared_ptr<::chatnow::auth::JwtCodec> &jwt_codec, const std::shared_ptr<::chatnow::auth::JwtStore> &jwt_store, const std::string &identity_service_name, @@ -68,7 +69,7 @@ class GatewayServer { const std::string &presence_service_name, const std::string &push_service_name) : _jwt_codec(jwt_codec), _jwt_store(jwt_store), - _channels(channels), _http_port(http_port), + _channels(channels), _discovery(discovery), _http_port(http_port), _identity_svc(identity_service_name), _relationship_svc(relationship_service_name), _conversation_svc(conversation_service_name), @@ -194,6 +195,18 @@ class GatewayServer { matched->handler(req, res, a, _channels, matched->timeout_ms, dummy_cntl); } + bool dependencies_ready() const { + return _channels && + _channels->available(_identity_svc) && + _channels->available(_relationship_svc) && + _channels->available(_conversation_svc) && + _channels->available(_message_svc) && + _channels->available(_transmite_svc) && + _channels->available(_media_svc) && + _channels->available(_presence_svc) && + _channels->available(_push_svc); + } + // ====== 路由注册 ====== void register_routes(); @@ -210,6 +223,7 @@ class GatewayServer { std::shared_ptr<::chatnow::auth::JwtCodec> _jwt_codec; std::shared_ptr<::chatnow::auth::JwtStore> _jwt_store; ServiceManager::ptr _channels; + Discovery::ptr _discovery; int _http_port; std::string _identity_svc; @@ -233,6 +247,18 @@ inline void GatewayServer::register_routes() { namespace med = ::chatnow::media; namespace pres = ::chatnow::presence; + _http_server.Get("/health", + [this](const httplib::Request&, httplib::Response& res) { + res.set_header("Cache-Control", "no-store"); + if (!dependencies_ready()) { + res.status = 503; + res.set_content("{\"status\":\"unavailable\"}", "application/json"); + return; + } + res.status = 200; + res.set_content("{\"status\":\"ready\"}", "application/json"); + }); + // ====== Identity (白名单) ====== route( "/service/identity/register", _identity_svc, GatewayAuth::WHITELISTED, @@ -521,7 +547,7 @@ class GatewayServerBuilder { auto discovery = std::make_shared(_reg_host, _base, put_cb, del_cb); return std::make_shared( - _http_port, channels, jwt_codec, jwt_store, + _http_port, channels, discovery, jwt_codec, jwt_store, _identity_svc, _relationship_svc, _conversation_svc, _message_svc, _transmite_svc, _media_svc, _presence_svc, _push_svc); diff --git a/media/source/media_main.cc b/media/source/media_main.cc index dc9825d..358ef96 100644 --- a/media/source/media_main.cc +++ b/media/source/media_main.cc @@ -54,6 +54,7 @@ struct LoadedMediaConf { chatnow::MediaServiceConfig cfg; std::shared_ptr mime; std::string s3_endpoint; + std::string s3_public_endpoint; std::string s3_region; }; @@ -75,8 +76,13 @@ LoadedMediaConf load_media_conf(const std::string& path) { LoadedMediaConf out; out.s3_endpoint = s3.get("endpoint", "").asString(); + out.s3_public_endpoint = s3.get("public_endpoint", "").asString(); out.s3_region = s3.get("region", "us-east-1").asString(); + if (out.s3_endpoint.empty() || out.s3_public_endpoint.empty()) { + throw std::runtime_error("media_conf: s3_endpoint_invalid"); + } + out.cfg.public_bucket = md.get("public_bucket", "").asString(); out.cfg.private_bucket = md.get("private_bucket", "").asString(); out.cfg.public_url_prefix = md.get("public_url_prefix", "").asString(); @@ -124,7 +130,7 @@ int main(int argc, char* argv[]) { b.set_redis_seeds(FLAGS_redis_seeds); b.make_redis_object(FLAGS_redis_host, FLAGS_redis_port, FLAGS_redis_db, FLAGS_redis_keep_alive); - b.make_s3_object(conf.s3_endpoint, conf.s3_region, + b.make_s3_object(conf.s3_endpoint, conf.s3_public_endpoint, conf.s3_region, s3_access_key, s3_secret_key); b.set_media_config(conf.cfg, conf.mime); b.make_registry_object(FLAGS_registry_host, diff --git a/media/source/media_server.h b/media/source/media_server.h index 6d41a54..2ca8285 100644 --- a/media/source/media_server.h +++ b/media/source/media_server.h @@ -295,9 +295,11 @@ class MediaServerBuilder { } } - void make_s3_object(const std::string& endpoint, const std::string& region, + void make_s3_object(const std::string& endpoint, const std::string& public_endpoint, + const std::string& region, const std::string& access_key, const std::string& secret_key) { - S3Options o{endpoint, region, access_key, secret_key, /*path_style*/true}; + S3Options o{endpoint, public_endpoint, region, access_key, secret_key, + /*path_style*/true}; _s3 = std::make_shared(o); } diff --git a/message/source/message_server.cc b/message/source/message_server.cc index b4e4663..58fc9ab 100644 --- a/message/source/message_server.cc +++ b/message/source/message_server.cc @@ -26,7 +26,7 @@ DEFINE_int32(mysql_port, 0, "MySQL服务器访问端口"); DEFINE_int32(mysql_pool_count, 4, "MySQL连接池最大连接数量"); DEFINE_string(mq_user, "root", "消息队列服务器访问用户名"); -DEFINE_string(mq_host, "127.0.0.1:5672", "消息队列服务器访问地址"); +DEFINE_string(mq_host, "127.0.0.1", "RabbitMQ hostname (port is fixed at 5672)"); DEFINE_string(mq_msg_exchange, "chat_msg_exchange", "持久化消息的发布交换机名称"); DEFINE_string(mq_msg_queue_db, "msg_queue_db", "持久化DB消息的发布队列名称"); DEFINE_string(mq_msg_queue_es, "msg_queue_es", "持久化ES消息的发布队列名称"); diff --git a/message/source/message_server.h b/message/source/message_server.h index e3b2a32..e13115f 100644 --- a/message/source/message_server.h +++ b/message/source/message_server.h @@ -1000,7 +1000,7 @@ class MessageServerBuilder { LOG_ERROR("Message MQ exchange 不能为空"); abort(); } - std::string amqp_url = "amqp://" + user + ":" + pwd + "@" + host + ":5672/"; + std::string amqp_url = make_amqp_url(user, pwd, host); _mq_client = std::make_shared(amqp_url); _db_queue_settings = { .exchange = exchange_name, diff --git a/push/source/push_server.cc b/push/source/push_server.cc index 09d3958..5d93690 100644 --- a/push/source/push_server.cc +++ b/push/source/push_server.cc @@ -27,7 +27,7 @@ DEFINE_bool(redis_keep_alive, true, "Redis 长连接"); DEFINE_int32(redis_pool_size, 16, "Redis 连接池大小"); DEFINE_string(mq_user, "root", "MQ 用户"); -DEFINE_string(mq_host, "127.0.0.1:5672", "MQ 地址"); +DEFINE_string(mq_host, "127.0.0.1", "RabbitMQ hostname (port is fixed at 5672)"); DEFINE_string(mq_push_exchange, "chat_push_exchange", "推送交换机"); DEFINE_string(mq_push_queue, "msg_push_queue", "推送队列"); DEFINE_string(mq_push_binding_key, "push", "推送绑定键"); diff --git a/push/source/push_server.h b/push/source/push_server.h index 2b3fac3..e811f07 100644 --- a/push/source/push_server.h +++ b/push/source/push_server.h @@ -1116,7 +1116,7 @@ class PushServerBuilder const std::string &queue, const std::string &binding_key) { - std::string amqp_url = "amqp://" + user + ":" + password + "@" + host + ":5672/"; + std::string amqp_url = make_amqp_url(user, password, host); _mq_client = std::make_shared(amqp_url); _push_settings = { .exchange = exchange, diff --git a/scripts/converge_mysql_users.sh b/scripts/converge_mysql_users.sh new file mode 100755 index 0000000..ddeeec8 --- /dev/null +++ b/scripts/converge_mysql_users.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +set -euo pipefail + +required=( + MYSQL_ROOT_PASSWORD + CHATNOW_IDENTITY_MYSQL_PASSWORD + CHATNOW_CONVERSATION_MYSQL_PASSWORD + CHATNOW_RELATIONSHIP_MYSQL_PASSWORD + CHATNOW_MESSAGE_MYSQL_PASSWORD + CHATNOW_MEDIA_MYSQL_PASSWORD +) + +for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + echo "mysql-init: required credential is missing: ${name}" >&2 + exit 1 + fi +done + +mysql_root() { + MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" mysql \ + --host=mysql --protocol=tcp --user=root --batch --skip-column-names "$@" +} + +secret_hex() { + printf '%s' "$1" | od -An -tx1 | tr -d ' \n' +} + +ensure_user() { + local user=$1 + local password_hex + password_hex=$(secret_hex "$2") + mysql_root mysql <&2 + exit 1 +fi + +mysql_root() { + MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" mysql \ + --host=mysql --protocol=tcp --user=root --batch --skip-column-names "$@" +} + +mysql_root <<'SQL' +CREATE DATABASE IF NOT EXISTS `chatnow` CHARACTER SET utf8mb4; +CREATE TABLE IF NOT EXISTS `chatnow`.`schema_migrations` ( + `version` varchar(128) NOT NULL PRIMARY KEY, + `checksum` char(64) NOT NULL, + `applied_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB; +SQL + +shopt -s nullglob +migrations=(/migrations/V*.sql) +if (( ${#migrations[@]} == 0 )); then + echo "mysql-init: no versioned SQL migrations found" >&2 + exit 1 +fi +mapfile -t migrations < <(printf '%s\n' "${migrations[@]}" | sort -V) + +for migration in "${migrations[@]}"; do + version=$(basename "$migration" .sql) + checksum=$(sha256sum "$migration" | awk '{print $1}') + applied_checksum=$(mysql_root --execute="SELECT checksum FROM chatnow.schema_migrations WHERE version='${version}'") + + if [[ -n "$applied_checksum" ]]; then + if [[ "$applied_checksum" != "$checksum" ]]; then + echo "mysql-init: checksum mismatch for applied migration ${version}; migration changed" >&2 + exit 1 + fi + continue + fi + + echo "mysql-init: applying ${version}" + mysql_root chatnow <"$migration" + mysql_root chatnow --execute="INSERT INTO schema_migrations(version, checksum) VALUES ('${version}', '${checksum}')" +done + +/init/converge_mysql_users.sh +echo "mysql-init: schema and application users are ready" diff --git a/scripts/init_rabbitmq.py b/scripts/init_rabbitmq.py new file mode 100644 index 0000000..6b6128e --- /dev/null +++ b/scripts/init_rabbitmq.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +import base64 +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request + + +def required(name: str) -> str: + value = os.environ.get(name, "") + if not value: + raise RuntimeError(f"required credential is missing: {name}") + return value + + +bootstrap_user = required("RABBITMQ_BOOTSTRAP_USER") +bootstrap_password = required("RABBITMQ_BOOTSTRAP_PASSWORD") +max_attempts = int(os.environ.get("RABBITMQ_INIT_MAX_ATTEMPTS", "60")) +authorization = base64.b64encode( + f"{bootstrap_user}:{bootstrap_password}".encode("utf-8") +).decode("ascii") + + +def management_request(method: str, path: str, payload: dict | None = None) -> None: + data = None if payload is None else json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + f"http://rabbitmq:15672{path}", + data=data, + method=method, + headers={ + "Authorization": f"Basic {authorization}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=3) as response: + if response.status < 200 or response.status >= 300: + raise RuntimeError(f"management request returned status {response.status}") + except urllib.error.HTTPError as error: + raise RuntimeError( + f"management request returned status {error.code} for {path}" + ) from None + + +for attempt in range(1, max_attempts + 1): + try: + management_request("GET", "/api/overview") + break + except (OSError, RuntimeError, urllib.error.URLError): + if attempt == max_attempts: + raise RuntimeError("management API did not become ready") from None + time.sleep(1) + + +users = { + "chatnow_transmite": { + "password": required("CHATNOW_TRANSMITE_MQ_PASSWORD"), + "configure": r"^chat_msg_exchange$", + "write": r"^chat_msg_exchange$", + "read": r"^$", + }, + "chatnow_message": { + "password": required("CHATNOW_MESSAGE_MQ_PASSWORD"), + "configure": r"^(chat_msg_exchange|chat_push_exchange|es_index_exchange|msg_queue_db|msg_queue_es|msg_queue_es_index|msg_push_queue)$", + "write": r"^(chat_push_exchange|es_index_exchange|msg_queue_db|msg_queue_es|msg_queue_es_index|msg_push_queue)$", + "read": r"^(chat_msg_exchange|chat_push_exchange|es_index_exchange|msg_queue_db|msg_queue_es|msg_queue_es_index)$", + }, + "chatnow_push": { + "password": required("CHATNOW_PUSH_MQ_PASSWORD"), + "configure": r"^(chat_push_exchange|msg_push_queue)$", + "write": r"^msg_push_queue$", + "read": r"^(chat_push_exchange|msg_push_queue)$", + }, +} + +for username, settings in users.items(): + encoded_user = urllib.parse.quote(username, safe="") + management_request( + "PUT", + f"/api/users/{encoded_user}", + {"password": settings["password"], "tags": ""}, + ) + management_request( + "PUT", + f"/api/permissions/%2F/{encoded_user}", + { + "configure": settings["configure"], + "write": settings["write"], + "read": settings["read"], + }, + ) + management_request("GET", f"/api/permissions/%2F/{encoded_user}") + +print("rabbitmq-init: application users and permissions are ready") diff --git a/scripts/init_redis_cluster.sh b/scripts/init_redis_cluster.sh new file mode 100755 index 0000000..307f3c9 --- /dev/null +++ b/scripts/init_redis_cluster.sh @@ -0,0 +1,56 @@ +#!/bin/sh + +set -eu + +MAX_ATTEMPTS=${REDIS_INIT_MAX_ATTEMPTS:-60} +NODES="redis-node1:6379 redis-node2:6380 redis-node3:6381 redis-node4:6382 redis-node5:6383 redis-node6:6384" + +attempt=1 +while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + all_ready=true + for node in $NODES; do + host=${node%:*} + port=${node#*:} + if ! redis-cli -h "$host" -p "$port" ping 2>/dev/null | grep -q '^PONG$'; then + all_ready=false + break + fi + done + if [ "$all_ready" = true ]; then + break + fi + attempt=$((attempt + 1)) + sleep 1 +done + +if [ "$attempt" -gt "$MAX_ATTEMPTS" ]; then + echo "redis-init: nodes did not become ready within ${MAX_ATTEMPTS} attempts" >&2 + exit 1 +fi + +cluster_info=$(redis-cli -h redis-node1 -p 6379 cluster info 2>/dev/null || true) +if ! printf '%s\n' "$cluster_info" | grep -q '^cluster_state:ok'; then + known_nodes=$(printf '%s\n' "$cluster_info" | sed -n 's/^cluster_known_nodes:\([0-9][0-9]*\).*/\1/p') + if [ -n "$known_nodes" ] && [ "$known_nodes" -gt 1 ]; then + echo "redis-init: existing cluster metadata is degraded; refusing to recreate it" >&2 + exit 1 + fi + + redis-cli --cluster create $NODES --cluster-replicas 1 --cluster-yes +fi + +attempt=1 +while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + cluster_info=$(redis-cli -h redis-node1 -p 6379 cluster info 2>/dev/null || true) + if printf '%s\n' "$cluster_info" | grep -q '^cluster_state:ok' && + printf '%s\n' "$cluster_info" | grep -q '^cluster_slots_assigned:16384'; then + redis-cli --cluster check redis-node1:6379 >/dev/null + echo "redis-init: cluster is ready with all 16384 slots assigned" + exit 0 + fi + attempt=$((attempt + 1)) + sleep 1 +done + +echo "redis-init: cluster did not converge within ${MAX_ATTEMPTS} attempts" >&2 +exit 1 diff --git a/scripts/wait_for_services.sh b/scripts/wait_for_services.sh new file mode 100755 index 0000000..99ac15e --- /dev/null +++ b/scripts/wait_for_services.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +set -euo pipefail + +READY_TIMEOUT_SEC=${CHATNOW_READY_TIMEOUT_SEC:-180} +POLL_INTERVAL_SEC=${CHATNOW_READY_POLL_INTERVAL_SEC:-2} +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$repo_root" + +deadline=$((SECONDS + READY_TIMEOUT_SEC)) + +compose() { + docker compose "$@" +} + +wait_until() { + local label=$1 + local probe=$2 + while (( SECONDS < deadline )); do + if "$probe"; then + echo "readiness: ${label} ready" + return 0 + fi + sleep "$POLL_INTERVAL_SEC" + done + echo "readiness: timed out waiting for ${label}" >&2 + return 1 +} + +probe_redis_cluster() { + local info + info=$(compose exec -T redis-node1 redis-cli -p 6379 cluster info 2>/dev/null) || return 1 + grep -q '^cluster_state:ok' <<<"$info" && + grep -q '^cluster_slots_assigned:16384' <<<"$info" && + grep -q '^cluster_slots_ok:16384' <<<"$info" && + grep -q '^cluster_known_nodes:6' <<<"$info" +} + +probe_mysql_schema() { + local count users + count=$(compose exec -T mysql sh -ec ' + MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql --user=root --batch --skip-column-names \ + --execute="SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '\''chatnow'\'' AND table_name IN ('\''conversation'\'', '\''conversation_member'\'', '\''friend_apply'\'', '\''media_blob_ref'\'', '\''media_file'\'', '\''media_user_quota'\'', '\''message'\'', '\''message_attachment'\'', '\''message_mention'\'', '\''message_pin'\'', '\''message_reaction'\'', '\''message_read'\'', '\''relation'\'', '\''user'\'', '\''user_block'\'', '\''user_device'\'', '\''user_timeline'\'');" chatnow + ' 2>/dev/null) || return 1 + [[ "$count" == "17" ]] || return 1 + + users=$(compose exec -T mysql sh -ec ' + MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql --user=root --batch --skip-column-names \ + --execute="SELECT COUNT(*) FROM mysql.user WHERE user IN ('\''chatnow_identity'\'', '\''chatnow_conversation'\'', '\''chatnow_relationship'\'', '\''chatnow_message'\'', '\''chatnow_media'\'');" + ' 2>/dev/null) || return 1 + [[ "$users" == "5" ]] +} + +probe_rabbitmq() { + compose exec -T rabbitmq rabbitmq-diagnostics -q check_running >/dev/null 2>&1 && + compose exec -T rabbitmq rabbitmq-diagnostics -q check_local_alarms >/dev/null 2>&1 || return 1 + + local users user + users=$(compose exec -T rabbitmq rabbitmqctl -q list_users 2>/dev/null) || return 1 + for user in chatnow_transmite chatnow_message chatnow_push; do + grep -Eq "^${user}[[:space:]]" <<<"$users" || return 1 + compose exec -T rabbitmq rabbitmqctl -q list_user_permissions "$user" 2>/dev/null | + grep -Eq '^/[[:space:]]' || return 1 + done +} + +probe_elasticsearch() { + local health + health=$(curl --fail --silent --show-error --max-time 2 \ + 'http://127.0.0.1:9200/_cluster/health?wait_for_status=yellow&timeout=1s') || return 1 + grep -Eq '"status":"(yellow|green)"' <<<"$health" +} + +probe_minio() { + curl --fail --silent --show-error --max-time 2 \ + 'http://127.0.0.1:19000/minio/health/ready' >/dev/null || return 1 + compose run --rm --no-deps --entrypoint /bin/sh minio-init -ec ' + mc alias set ready http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null + mc stat ready/chatnow-media-public >/dev/null + mc stat ready/chatnow-media-private >/dev/null + ' >/dev/null 2>&1 +} + +probe_etcd_registrations() { + local keys service count + keys=$(compose exec -T etcd sh -ec \ + 'ETCDCTL_API=3 etcdctl --endpoints=http://127.0.0.1:2379 get /service --prefix --keys-only' \ + 2>/dev/null) || return 1 + for service in identity media transmite message relationship conversation presence push; do + count=$(grep -c "^/service/${service}_service/instance$" <<<"$keys" || true) + [[ "$count" == "1" ]] || return 1 + done + count=$(sed -n 's#^/service/\([^/]*_service\)/instance$#\1#p' <<<"$keys" | sort -u | wc -l) + [[ "${count//[[:space:]]/}" == "8" ]] +} + +probe_gateway() { + curl --fail --silent --show-error --max-time 2 \ + 'http://127.0.0.1:9000/health' >/dev/null +} + +probe_push() { + nc -z -w 2 127.0.0.1 9001 +} + +wait_until "Redis Cluster" probe_redis_cluster +wait_until "MySQL schema and application users" probe_mysql_schema +wait_until "RabbitMQ" probe_rabbitmq +wait_until "Elasticsearch" probe_elasticsearch +wait_until "MinIO buckets" probe_minio +wait_until "eight exact etcd service registrations" probe_etcd_registrations +wait_until "Gateway HTTP health" probe_gateway +wait_until "Push WebSocket port 9001" probe_push + +echo "readiness: full stack is ready" diff --git a/sql/V1__core.sql b/sql/V1__core.sql new file mode 100644 index 0000000..ca60c3d --- /dev/null +++ b/sql/V1__core.sql @@ -0,0 +1,325 @@ +/* This file was generated by ODB, object-relational mapping (ORM) + * compiler for C++. + */ + +CREATE TABLE IF NOT EXISTS `conversation` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `conversation_id` varchar(48) NOT NULL, + `conversation_name` varchar(64) NULL, + `conversation_type` tinyint unsigned NOT NULL, + `create_time` DATETIME(3) NULL, + `member_count` int unsigned NOT NULL, + `status` tinyint unsigned NOT NULL, + `avatar_id` varchar(64) NULL, + `owner_id` varchar(32) NULL, + `peer_user_id` varchar(32) NULL, + `description` varchar(255) NULL, + `announcement` varchar(1024) NULL, + `muted_all` tinyint(1) NOT NULL, + `max_seq` bigint unsigned NOT NULL, + `update_time` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `conversation_id_i` + ON `conversation` (`conversation_id`); + +CREATE TABLE IF NOT EXISTS `conversation_member` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `conversation_id` varchar(48) NOT NULL, + `user_id` varchar(32) NOT NULL, + `last_read_seq` bigint unsigned NOT NULL, + `last_ack_seq` bigint unsigned NOT NULL, + `muted` tinyint(1) NOT NULL, + `visible` tinyint(1) NOT NULL, + `pin_time` DATETIME(3) NULL, + `role` tinyint unsigned NOT NULL, + `alias` varchar(64) NULL, + `inviter_id` varchar(32) NULL, + `join_source` tinyint unsigned NOT NULL, + `join_time` DATETIME(3) NULL, + `mute_until` DATETIME(3) NULL, + `is_quit` tinyint(1) NOT NULL, + `quit_time` DATETIME(3) NULL, + `draft` text NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `uk_conv_user` + ON `conversation_member` ( + `conversation_id`, + `user_id`); + +CREATE INDEX `idx_user_conv` + ON `conversation_member` ( + `user_id`, + `is_quit`, + `conversation_id`); + +CREATE TABLE IF NOT EXISTS `friend_apply` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `event_id` varchar(32) NOT NULL, + `user_id` varchar(32) NOT NULL, + `peer_id` varchar(32) NOT NULL, + `status` tinyint unsigned NOT NULL, + `source` tinyint unsigned NOT NULL, + `greeting` varchar(255) NULL, + `remark` varchar(64) NULL, + `create_time` DATETIME(3) NULL, + `handle_time` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `event_id_i` + ON `friend_apply` (`event_id`); + +CREATE INDEX `idx_peer_status_time` + ON `friend_apply` ( + `peer_id`, + `status`, + `create_time`); + +CREATE INDEX `idx_user_peer_time` + ON `friend_apply` ( + `user_id`, + `peer_id`, + `create_time`); + +CREATE TABLE IF NOT EXISTS `message` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `message_id` bigint unsigned NOT NULL, + `seq_id` bigint unsigned NOT NULL, + `session_id` varchar(48) NOT NULL, + `user_id` varchar(32) NOT NULL, + `message_type` tinyint unsigned NOT NULL, + `create_time` DATETIME(3) NULL, + `content` text NULL, + `client_msg_id` varchar(64) NULL, + `file_id` varchar(64) NULL, + `file_name` varchar(255) NULL, + `file_size` bigint unsigned NULL, + `reply_to_msg_id` bigint unsigned NULL, + `status` tinyint unsigned NOT NULL, + `revoke_time` DATETIME(3) NULL, + `revoke_by` varchar(32) NULL, + `edit_time` DATETIME(3) NULL, + `forward_from_uid` varchar(32) NULL, + `forward_at` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `message_id_i` + ON `message` (`message_id`); + +CREATE UNIQUE INDEX `uk_session_seq` + ON `message` ( + `session_id`, + `seq_id`); + +CREATE UNIQUE INDEX `uk_client_msg` + ON `message` ( + `user_id`, + `client_msg_id`); + +CREATE TABLE IF NOT EXISTS `message_attachment` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `message_id` bigint unsigned NOT NULL, + `order_idx` int NOT NULL, + `att_type` tinyint unsigned NOT NULL, + `file_id` varchar(64) NOT NULL, + `file_name` varchar(255) NULL, + `file_size` bigint unsigned NOT NULL, + `mime_type` varchar(64) NULL, + `thumb_file_id` varchar(64) NULL, + `width` int unsigned NULL, + `height` int unsigned NULL, + `duration_ms` int unsigned NULL, + `asr_text` text NULL, + `create_time` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE INDEX `idx_message` + ON `message_attachment` ( + `message_id`, + `order_idx`); + +CREATE TABLE IF NOT EXISTS `message_mention` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `message_id` bigint unsigned NOT NULL, + `user_id` varchar(32) NOT NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `uk_msg_user` + ON `message_mention` ( + `message_id`, + `user_id`); + +CREATE INDEX `idx_user_msg` + ON `message_mention` ( + `user_id`, + `message_id`); + +CREATE TABLE IF NOT EXISTS `message_pin` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `session_id` varchar(48) NOT NULL, + `message_id` bigint unsigned NOT NULL, + `pinned_by` varchar(32) NOT NULL, + `pinned_at` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `uk_conv_msg` + ON `message_pin` ( + `session_id`, + `message_id`); + +CREATE INDEX `idx_conv` + ON `message_pin` (`session_id`); + +CREATE TABLE IF NOT EXISTS `message_reaction` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `message_id` bigint unsigned NOT NULL, + `user_id` varchar(32) NOT NULL, + `emoji` varchar(16) NOT NULL, + `create_time` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `uk_msg_user_emoji` + ON `message_reaction` ( + `message_id`, + `user_id`, + `emoji`); + +CREATE INDEX `idx_msg` + ON `message_reaction` (`message_id`); + +CREATE TABLE IF NOT EXISTS `message_read` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `message_id` bigint unsigned NOT NULL, + `user_id` varchar(32) NOT NULL, + `read_time` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `uk_msg_user` + ON `message_read` ( + `message_id`, + `user_id`); + +CREATE INDEX `idx_msg_time` + ON `message_read` ( + `message_id`, + `read_time`); + +CREATE TABLE IF NOT EXISTS `relation` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `user_id` varchar(32) NOT NULL, + `peer_id` varchar(32) NOT NULL, + `remark` varchar(64) NULL, + `group_name` varchar(32) NULL, + `status` tinyint unsigned NOT NULL, + `starred` tinyint(1) NOT NULL, + `create_time` DATETIME(3) NULL, + `update_time` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `uk_user_peer` + ON `relation` ( + `user_id`, + `peer_id`); + +CREATE INDEX `idx_peer_user` + ON `relation` ( + `peer_id`, + `user_id`); + +CREATE TABLE IF NOT EXISTS `user` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `user_id` varchar(32) NOT NULL, + `nickname` varchar(64) NULL, + `description` varchar(255) NULL, + `password` varchar(256) NULL, + `password_salt` varchar(64) NULL, + `mail` varchar(128) NULL, + `phone` varchar(20) NULL, + `avatar_id` varchar(64) NULL, + `gender` tinyint unsigned NOT NULL, + `region` varchar(64) NULL, + `status` tinyint unsigned NOT NULL, + `register_time` DATETIME(3) NULL, + `last_login_time` DATETIME(3) NULL, + `last_login_ip` varchar(45) NULL, + `update_time` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `user_id_i` + ON `user` (`user_id`); + +CREATE INDEX `nickname_i` + ON `user` (`nickname`); + +CREATE UNIQUE INDEX `mail_i` + ON `user` (`mail`); + +CREATE UNIQUE INDEX `phone_i` + ON `user` (`phone`); + +CREATE TABLE IF NOT EXISTS `user_block` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `blocker_id` varchar(32) NOT NULL, + `blocked_id` varchar(32) NOT NULL, + `create_time` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `uk_blocker_blocked` + ON `user_block` ( + `blocker_id`, + `blocked_id`); + +CREATE INDEX `idx_blocked` + ON `user_block` (`blocked_id`); + +CREATE TABLE IF NOT EXISTS `user_device` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `user_id` varchar(32) NOT NULL, + `device_id` varchar(128) NOT NULL, + `device_type` tinyint unsigned NOT NULL, + `device_name` varchar(64) NULL, + `app_version` varchar(32) NULL, + `os_version` varchar(32) NULL, + `login_session_id` varchar(64) NOT NULL, + `push_token` varchar(255) NULL, + `login_ip` varchar(45) NULL, + `online_status` tinyint unsigned NOT NULL, + `last_active_time` DATETIME(3) NULL, + `login_time` DATETIME(3) NULL, + `expire_time` DATETIME(3) NULL, + `update_time` DATETIME(3) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `login_session_id_i` + ON `user_device` (`login_session_id`); + +CREATE UNIQUE INDEX `uk_user_device` + ON `user_device` ( + `user_id`, + `device_id`); + +CREATE UNIQUE INDEX `uk_push_token` + ON `user_device` (`push_token`); + +CREATE TABLE IF NOT EXISTS `user_timeline` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `user_id` varchar(32) NOT NULL, + `user_seq` bigint unsigned NOT NULL, + `session_id` varchar(48) NOT NULL, + `session_seq` bigint unsigned NOT NULL, + `message_id` bigint unsigned NOT NULL, + `message_time` DATETIME(3) NULL, + `deliver_status` tinyint unsigned NOT NULL) + ENGINE=InnoDB; + +CREATE INDEX `idx_user_seq` + ON `user_timeline` ( + `user_id`, + `user_seq`); + +CREATE INDEX `idx_user_session_seq` + ON `user_timeline` ( + `user_id`, + `session_id`, + `session_seq`); diff --git a/sql/V4__media.sql b/sql/V4__media.sql index eddd1ef..3c66754 100644 --- a/sql/V4__media.sql +++ b/sql/V4__media.sql @@ -1,56 +1,58 @@ --- =========================================================================== --- V4__media.sql —— 媒体子系统三张表 DDL --- --------------------------------------------------------------------------- --- 注意:本仓的权威 schema 由 ODB 通过 odb/media_*.hxx 的 --- `--generate-schema` 自动生成(见 file/CMakeLists.txt)。 --- 本文件仅作部署参考与 code review 友好性;与 ODB 输出冲突时以 ODB 为准。 --- --------------------------------------------------------------------------- --- 表关系: --- media_file 元数据 + 状态机(pending / committed / deleted / quarantined) --- media_blob_ref 按 content_hash 去重的物理对象引用计数 --- media_user_quota 用户级配额(默认 5 GB) --- =========================================================================== - --- 媒体文件元数据 -CREATE TABLE IF NOT EXISTS media_file ( - id BIGINT NOT NULL AUTO_INCREMENT, - file_id VARCHAR(20) NOT NULL, -- 业务唯一 ID(snowflake Next() → 16 hex) - content_hash VARCHAR(72) NOT NULL, -- "sha256:<64hex>" - bucket VARCHAR(64) NOT NULL, - object_key VARCHAR(255) NOT NULL, - file_name VARCHAR(255) NOT NULL DEFAULT '', - file_size BIGINT NOT NULL, - mime_type VARCHAR(128) NOT NULL, - purpose TINYINT UNSIGNED NOT NULL, -- MediaPurpose enum - owner_id VARCHAR(32) NOT NULL, -- user_id - uploaded_at DATETIME(3) NOT NULL, - status TINYINT UNSIGNED NOT NULL, -- 0 pending / 1 committed / 2 deleted / 3 quarantined - upload_id VARCHAR(128) NULL, -- multipart upload_id (S3) - PRIMARY KEY (id), - UNIQUE KEY uq_file_id (file_id), - KEY idx_hash (content_hash), - KEY idx_owner_uploaded (owner_id, uploaded_at), - KEY idx_status_uploaded (status, uploaded_at), - KEY idx_upload_id (upload_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- 物理 blob 的引用计数(按 content_hash 去重) -CREATE TABLE IF NOT EXISTS media_blob_ref ( - content_hash VARCHAR(72) NOT NULL, - bucket VARCHAR(64) NOT NULL, - object_key VARCHAR(255) NOT NULL, - ref_count INT NOT NULL DEFAULT 0, - total_size BIGINT NOT NULL, - last_decremented_at DATETIME(3) NOT NULL, - PRIMARY KEY (content_hash), - KEY idx_refcount_decremented (ref_count, last_decremented_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- 用户级配额 -CREATE TABLE IF NOT EXISTS media_user_quota ( - user_id VARCHAR(32) NOT NULL, - used_bytes BIGINT NOT NULL DEFAULT 0, - quota_bytes BIGINT NOT NULL DEFAULT 5368709120, -- 5 GB - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (user_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +/* This file was generated by ODB, object-relational mapping (ORM) + * compiler for C++. + */ + +CREATE TABLE IF NOT EXISTS `media_blob_ref` ( + `content_hash` varchar(72) NOT NULL PRIMARY KEY, + `bucket` varchar(64) NOT NULL, + `object_key` varchar(255) NOT NULL, + `ref_count` int NOT NULL, + `total_size` bigint NOT NULL, + `last_decremented_at` DATETIME(3) NULL) + ENGINE=InnoDB; +CREATE INDEX `idx_refcount_decremented` + ON `media_blob_ref` ( + `ref_count`, + `last_decremented_at`); + +CREATE TABLE IF NOT EXISTS `media_file` ( + `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + `file_id` varchar(20) NOT NULL, + `content_hash` varchar(72) NOT NULL, + `bucket` varchar(64) NOT NULL, + `object_key` varchar(255) NOT NULL, + `file_name` varchar(255) NOT NULL, + `file_size` bigint NOT NULL, + `mime_type` varchar(128) NOT NULL, + `purpose` tinyint unsigned NOT NULL, + `owner_id` varchar(32) NOT NULL, + `uploaded_at` DATETIME(3) NULL, + `status` tinyint unsigned NOT NULL, + `upload_id` varchar(128) NULL) + ENGINE=InnoDB; + +CREATE UNIQUE INDEX `file_id_i` + ON `media_file` (`file_id`); + +CREATE INDEX `idx_hash` + ON `media_file` (`content_hash`); + +CREATE INDEX `idx_owner_uploaded` + ON `media_file` ( + `owner_id`, + `uploaded_at`); + +CREATE INDEX `idx_status_uploaded` + ON `media_file` ( + `status`, + `uploaded_at`); + +CREATE INDEX `idx_upload_id` + ON `media_file` (`upload_id`); + +CREATE TABLE IF NOT EXISTS `media_user_quota` ( + `user_id` varchar(32) NOT NULL PRIMARY KEY, + `used_bytes` bigint NOT NULL, + `quota_bytes` bigint NOT NULL, + `updated_at` DATETIME(3) NULL) + ENGINE=InnoDB; diff --git a/tests/pkg/contracts/compose_runtime_test.go b/tests/pkg/contracts/compose_runtime_test.go new file mode 100644 index 0000000..da57091 --- /dev/null +++ b/tests/pkg/contracts/compose_runtime_test.go @@ -0,0 +1,582 @@ +package contracts + +import ( + "encoding/json" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +type composeRuntimeDocument struct { + Services map[string]composeRuntimeService `yaml:"services"` +} + +type composeRuntimeService struct { + Build any `yaml:"build"` + Command any `yaml:"command"` + Entrypoint any `yaml:"entrypoint"` + DependsOn any `yaml:"depends_on"` + Environment any `yaml:"environment"` + Healthcheck any `yaml:"healthcheck"` + Hostname string `yaml:"hostname"` + Ports []any `yaml:"ports"` + Restart any `yaml:"restart"` + Volumes []any `yaml:"volumes"` +} + +// Repository contract | P0 | The root Compose topology includes usable object storage. +func TestComposeRuntimeTopologyContract(t *testing.T) { + root := repositoryRoot(t) + compose := readRuntimeCompose(t, root) + + t.Run("root stack defines MinIO", func(t *testing.T) { + minio, exists := compose.Services["minio"] + require.True(t, exists, "root docker-compose.yml must define minio") + require.NotNil(t, minio.Healthcheck, "minio must expose a healthcheck for its initializer") + }) + + t.Run("MinIO initializer is bounded and one-shot", func(t *testing.T) { + initializer, exists := compose.Services["minio-init"] + require.True(t, exists, "root docker-compose.yml must define minio-init") + require.Equal(t, "no", strings.ToLower(fmt.Sprint(initializer.Restart)), + "minio-init must be a one-shot service") + require.Equal(t, "service_healthy", dependencyCondition(initializer.DependsOn, "minio"), + "minio-init must wait for MinIO health") + initializerText := serviceCommandText(initializer) + require.NotRegexp(t, regexp.MustCompile(`(?i)\btail\s+-f\b`), initializerText, + "minio-init must exit after bucket initialization") + require.Contains(t, fmt.Sprint(initializer.Build), "docker/minio-init", + "the shell-based initializer must use the repository init image, not the shell-less mc image") + dockerfile, err := os.ReadFile(filepath.Join(root, "docker/minio-init/Dockerfile")) + require.NoError(t, err) + require.Regexp(t, regexp.MustCompile(`(?im)^FROM\s+(?:busybox|alpine):[^\s]+\s*$`), string(dockerfile), + "the final MinIO init stage must provide /bin/sh") + require.Regexp(t, regexp.MustCompile(`(?im)^COPY\s+--from=mc\s+/usr/bin/mc\s+/usr/bin/mc\s*$`), string(dockerfile), + "the init image must copy the pinned official mc binary") + entrypoint, err := os.ReadFile(filepath.Join(root, "docker/minio-init/entrypoint.sh")) + require.NoError(t, err) + entrypointSource := string(entrypoint) + require.Contains(t, entrypointSource, "MC_CONFIG_DIR", + "temporary mc state must be isolated and removed") + require.NotRegexp(t, + regexp.MustCompile(`(?m)^\s*mc\s+alias\s+set[^\n]*(?:MINIO_ROOT_PASSWORD|\$\{?MINIO_ROOT_PASSWORD)`), + entrypointSource, "the MinIO root password must not enter process arguments") + require.NotRegexp(t, + regexp.MustCompile(`(?m)^\s*mc\s+admin\s+user\s+add[^\n]*(?:MINIO_APP_SECRET_KEY|\$\{?MINIO_APP_SECRET_KEY)`), + entrypointSource, "the MinIO application secret must not enter process arguments") + }) + + t.Run("Media waits for bucket initialization", func(t *testing.T) { + media, exists := compose.Services["media_server"] + require.True(t, exists, "root docker-compose.yml must define media_server") + require.Equal(t, "service_completed_successfully", + dependencyCondition(media.DependsOn, "minio-init"), + "Media must not start before the one-shot bucket initializer succeeds") + }) + + t.Run("Media uses service DNS for S3", func(t *testing.T) { + media := compose.Services["media_server"] + configSource, readOnly, found := findVolumeMount(media.Volumes, "/im/conf/media.json") + require.True(t, found, "Media must mount its S3 configuration") + configSource = strings.TrimPrefix(filepath.ToSlash(configSource), "./") + mediaConfigBytes, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(configSource))) + require.NoError(t, err) + var mediaConfig struct { + S3 struct { + Endpoint string `json:"endpoint"` + PublicEndpoint string `json:"public_endpoint"` + } `json:"s3"` + } + require.NoError(t, json.Unmarshal(mediaConfigBytes, &mediaConfig)) + endpoint, err := url.Parse(mediaConfig.S3.Endpoint) + require.NoError(t, err) + require.Equal(t, "minio", endpoint.Hostname(), + "Media S3 endpoint must resolve through the root Compose MinIO service") + require.Equal(t, "9000", endpoint.Port()) + require.False(t, isLoopbackHostname(endpoint.Hostname()), + "a container dependency endpoint must not use loopback") + + publicEndpoint, err := url.Parse(mediaConfig.S3.PublicEndpoint) + require.NoError(t, err) + require.True(t, isLoopbackHostname(publicEndpoint.Hostname()), + "local/CI presigned URLs must be reachable from the host client") + require.Equal(t, "19000", publicEndpoint.Port()) + require.NotEqual(t, endpoint.Host, publicEndpoint.Host, + "internal S3 requests and client-facing presigned URLs need distinct endpoints") + + s3Client, err := os.ReadFile(filepath.Join(root, "common/infra/s3_client.hpp")) + require.NoError(t, err) + require.Contains(t, string(s3Client), "public_endpoint", + "S3Client must support a client-facing presign endpoint") + require.Contains(t, string(s3Client), "_presign_client", + "presigning must not reuse the internal service-DNS client") + require.True(t, readOnly, "Media configuration must be mounted read-only") + + conversationConfig, err := os.ReadFile(filepath.Join(root, "conf/docker/conversation_server.conf")) + require.NoError(t, err) + require.Contains(t, string(conversationConfig), + "-public_url_prefix=http://127.0.0.1:19000/chatnow-media-public", + "Conversation-generated public media URLs must use the client-facing MinIO endpoint") + conversationSource, err := os.ReadFile(filepath.Join(root, "conversation/source/conversation_server.cc")) + require.NoError(t, err) + require.Contains(t, string(conversationSource), + `"http://127.0.0.1:19000/chatnow-media-public"`) + }) + + t.Run("published host ports are unique", func(t *testing.T) { + owners := make(map[string]string) + for serviceName, service := range compose.Services { + for _, rawPort := range service.Ports { + hostPort, published := publishedHostPort(rawPort) + if !published { + continue + } + if previous, duplicate := owners[hostPort]; duplicate { + t.Errorf("host port %s is published by both %s and %s", hostPort, previous, serviceName) + continue + } + owners[hostPort] = serviceName + } + } + }) + + t.Run("Redis cluster initialization has no fixed sleep", func(t *testing.T) { + initializer, exists := compose.Services["redis-cluster-init"] + require.True(t, exists, "root docker-compose.yml must define redis-cluster-init") + initializerText := serviceCommandText(initializer) + require.NotRegexp(t, regexp.MustCompile(`(?i)\bsleep\s+[0-9]+(?:s)?\b`), initializerText, + "a fixed sleep is not Redis Cluster readiness evidence") + }) + + t.Run("Redis cluster initializer exits", func(t *testing.T) { + initializer, exists := compose.Services["redis-cluster-init"] + require.True(t, exists, "root docker-compose.yml must define redis-cluster-init") + initializerText := serviceCommandText(initializer) + require.NotRegexp(t, regexp.MustCompile(`(?i)\btail\s+-f\b`), initializerText, + "redis-cluster-init must exit after convergence") + }) + + t.Run("Redis cluster initialization is bounded", func(t *testing.T) { + initializer, exists := compose.Services["redis-cluster-init"] + require.True(t, exists, "root docker-compose.yml must define redis-cluster-init") + initializerText := serviceCommandText(initializer) + initializerScript, err := os.ReadFile(filepath.Join(root, "scripts/init_redis_cluster.sh")) + require.NoError(t, err) + initializerText += "\n" + string(initializerScript) + require.True(t, declaresBoundedDeadline(initializerText), + "redis-cluster-init must declare and enforce a bounded deadline or attempt limit") + }) + + t.Run("Redis Cluster advertises stable service DNS", func(t *testing.T) { + for index := 1; index <= 6; index++ { + serviceName := fmt.Sprintf("redis-node%d", index) + node, exists := compose.Services[serviceName] + require.True(t, exists) + command := serviceCommandText(node) + require.Contains(t, command, "--cluster-announce-hostname "+serviceName) + require.Contains(t, command, "--cluster-preferred-endpoint-type hostname") + } + }) + + t.Run("RabbitMQ persistent node identity is stable", func(t *testing.T) { + rabbit, exists := compose.Services["rabbitmq"] + require.True(t, exists) + require.Equal(t, "rabbitmq", rabbit.Hostname, + "RabbitMQ must keep a stable node identity across container recreation") + nodeName, declared := environmentValue(rabbit.Environment, "RABBITMQ_NODENAME") + require.True(t, declared) + require.Equal(t, "rabbit@rabbitmq", nodeName) + }) + +} + +// Repository contract | P0 | RabbitMQ hosts are not expanded into invalid double-port URLs. +func TestRabbitMQRuntimeAddressContract(t *testing.T) { + root := repositoryRoot(t) + for _, relativePath := range []string{ + "conf/docker/message_server.conf", + "conf/docker/push_server.conf", + "conf/docker/transmite_server.conf", + "conf/local/message_server.conf", + "conf/local/push_server.conf", + "conf/local/transmite_server.conf", + "conf/transmite_server.conf.example", + "message/source/message_server.cc", + "push/source/push_server.cc", + "transmite/source/transmite_server.cc", + } { + content, err := os.ReadFile(filepath.Join(root, relativePath)) + require.NoError(t, err) + mqDefault := regexp.MustCompile(`DEFINE_string\s*\(\s*mq_host\s*,\s*"([^"]+)"`) + for _, line := range strings.Split(string(content), "\n") { + trimmed := strings.TrimSpace(line) + host := "" + if strings.HasPrefix(trimmed, "-mq_host=") { + host = strings.TrimSpace(strings.TrimPrefix(trimmed, "-mq_host=")) + } else if match := mqDefault.FindStringSubmatch(line); len(match) == 2 { + host = match[1] + } + if host == "" { + continue + } + require.NotContains(t, host, ":", + "%s must contain a host only; RabbitMQ builders append port 5672", relativePath) + } + } +} + +// Repository contract | P0 | RabbitMQ credentials cannot change AMQP URI structure. +func TestRabbitMQRuntimeCredentialEncodingContract(t *testing.T) { + root := repositoryRoot(t) + helper, err := os.ReadFile(filepath.Join(root, "common/mq/amqp_url.hpp")) + require.NoError(t, err, "a shared AMQP URI builder must encode credentials") + helperSource := string(helper) + require.Contains(t, helperSource, "percent_encode_userinfo") + require.Contains(t, helperSource, "make_amqp_url") + + for _, relativePath := range []string{ + "message/source/message_server.h", + "push/source/push_server.h", + "transmite/source/transmite_server.h", + } { + content, err := os.ReadFile(filepath.Join(root, relativePath)) + require.NoError(t, err) + require.Contains(t, string(content), "make_amqp_url(", + "%s must not concatenate credentials into an AMQP URI", relativePath) + } +} + +// Repository contract | P0 | A clean MySQL volume creates every current ODB object table. +func TestComposeDatabaseBootstrapContract(t *testing.T) { + root := repositoryRoot(t) + compose := readRuntimeCompose(t, root) + mysql, exists := compose.Services["mysql"] + require.True(t, exists, "root docker-compose.yml must define mysql") + + t.Run("database name is declared", func(t *testing.T) { + databaseName, declared := environmentValue(mysql.Environment, "MYSQL_DATABASE") + require.True(t, declared, "MySQL must declare MYSQL_DATABASE") + require.True(t, databaseName == "chatnow" || strings.HasSuffix(databaseName, ":-chatnow}"), + "MYSQL_DATABASE must resolve to chatnow, got %q", databaseName) + }) + + t.Run("migration mount is read-only", func(t *testing.T) { + initializer, exists := compose.Services["mysql-init"] + require.True(t, exists, "root docker-compose.yml must define mysql-init") + require.Equal(t, "service_healthy", dependencyCondition(initializer.DependsOn, "mysql"), + "mysql-init must wait for MySQL health") + require.Equal(t, "no", strings.ToLower(fmt.Sprint(initializer.Restart)), + "mysql-init must be a one-shot service") + _, readOnly, found := findVolumeMount(initializer.Volumes, "/migrations") + require.True(t, found, "mysql-init must mount the versioned sql directory") + require.True(t, readOnly, "the migration mount must be read-only") + }) + + t.Run("application services wait for schema convergence", func(t *testing.T) { + for _, serviceName := range []string{ + "identity_server", "conversation_server", "relationship_server", + "message_server", "media_server", + } { + service, exists := compose.Services[serviceName] + require.True(t, exists) + require.Equal(t, "service_completed_successfully", + dependencyCondition(service.DependsOn, "mysql-init"), + "%s must not start before schema and grants converge", serviceName) + } + }) + + t.Run("migration runner rejects changed applied versions", func(t *testing.T) { + path := filepath.Join(root, "scripts/init_mysql.sh") + info, err := os.Stat(path) + require.NoError(t, err, "a repeatable MySQL migration runner must exist") + require.NotZero(t, info.Mode().Perm()&0o111, "MySQL migration runner must be executable") + content, err := os.ReadFile(path) + require.NoError(t, err) + source := strings.ToLower(string(content)) + require.Contains(t, source, "schema_migrations") + require.Contains(t, source, "sha256") + require.Contains(t, source, "checksum") + require.Regexp(t, regexp.MustCompile(`(?is)checksum.{0,500}(?:mismatch|changed).{0,500}exit\s+1`), source, + "an applied migration whose checksum changes must fail closed") + }) + + t.Run("migrations cover current ODB object tables idempotently", func(t *testing.T) { + objectTables := odbObjectTables(t, root) + require.NotEmpty(t, objectTables) + migrationTables := migrationTableContracts(t, root) + + var missing []string + var nonIdempotent []string + for _, table := range objectTables { + idempotent, present := migrationTables[table] + if !present { + missing = append(missing, table) + continue + } + if !idempotent { + nonIdempotent = append(nonIdempotent, table) + } + } + require.Empty(t, missing, "versioned migrations are missing current ODB object tables") + require.Empty(t, nonIdempotent, + "cold-start CREATE TABLE statements must use IF NOT EXISTS") + }) +} + +// Repository contract | P0 | CI readiness has a bounded semantic helper. +func TestRuntimeReadinessHelperContract(t *testing.T) { + root := repositoryRoot(t) + path := filepath.Join(root, "scripts/wait_for_services.sh") + info, err := os.Stat(path) + require.NoError(t, err, "scripts/wait_for_services.sh must exist") + require.True(t, info.Mode().IsRegular()) + require.NotZero(t, info.Mode().Perm()&0o111, + "scripts/wait_for_services.sh must be executable") + + content, err := os.ReadFile(path) + require.NoError(t, err) + source := string(content) + require.True(t, declaresBoundedDeadline(source), + "readiness must declare and enforce a bounded deadline or attempt limit") + + probes := map[string]*regexp.Regexp{ + "Redis Cluster state": regexp.MustCompile( + `(?is)redis-cli.{0,300}cluster\s+info.{0,300}cluster_state\s*:\s*ok`), + "MySQL schema": regexp.MustCompile( + `(?is)(?:mysql|mariadb).{0,300}(?:information_schema\.tables|show\s+tables)`), + "RabbitMQ readiness": regexp.MustCompile( + `(?is)(?:rabbitmq-diagnostics.{0,160}(?:ping|check_running)|/api/health/checks/(?:ready-to-serve-clients|alarms|local-alarms))`), + "Elasticsearch health": regexp.MustCompile(`(?is)_cluster/health`), + "MinIO health": regexp.MustCompile(`(?is)/minio/health/(?:ready|live)`), + "public MinIO bucket": regexp.MustCompile( + `(?is)(?:mc|mcli)\s+(?:stat|ls).{0,240}chatnow-media-public`), + "private MinIO bucket": regexp.MustCompile( + `(?is)(?:mc|mcli)\s+(?:stat|ls).{0,240}chatnow-media-private`), + "etcd service registrations": regexp.MustCompile( + `(?is)(?:etcdctl.{0,240}get.{0,240}(?:--prefix.{0,120}/service|/service.{0,120}--prefix)|/v3/kv/range)`), + "Gateway HTTP readiness": regexp.MustCompile( + `(?is)(?:curl|wget).{0,240}(?:gateway|127\.0\.0\.1|localhost).{0,160}/health`), + "Push service readiness": regexp.MustCompile( + `(?is)(?:nc|curl|wget|websocat).{0,240}(?:push|127\.0\.0\.1|localhost).{0,160}(?:9001|health|ready)`), + } + for name, pattern := range probes { + require.True(t, pattern.MatchString(source), "readiness helper must probe %s", name) + } +} + +// Repository contract | P0 | Gateway health is dependency-aware, not liveness-only. +func TestGatewayHealthReadinessContract(t *testing.T) { + root := repositoryRoot(t) + header, err := os.ReadFile(filepath.Join(root, "gateway/source/gateway_server.h")) + require.NoError(t, err) + implementation, err := os.ReadFile(filepath.Join(root, "gateway/source/gateway_server.cc")) + require.NoError(t, err) + source := string(header) + "\n" + string(implementation) + + registration := regexp.MustCompile(`(?s)_http_server\s*\.\s*Get\s*\(\s*"/health"`) + require.True(t, registration.MatchString(source), "Gateway must register GET /health") + + healthOffset := strings.Index(source, `"/health"`) + require.GreaterOrEqual(t, healthOffset, 0) + healthEnd := healthOffset + 3000 + if healthEnd > len(source) { + healthEnd = len(source) + } + healthContract := source[healthOffset:healthEnd] + require.True(t, + regexp.MustCompile(`(?i)(ready|readiness|dependenc|service_manager|_channels|redis|etcd)`).MatchString(healthContract), + "Gateway health must inspect dependencies") + require.True(t, + regexp.MustCompile(`(?i)(status\s*=\s*50[023]|service_unavailable|unavailable)`).MatchString(healthContract), + "Gateway health must return a non-200 result when dependencies are unavailable") +} + +func readRuntimeCompose(t testing.TB, root string) composeRuntimeDocument { + t.Helper() + content, err := os.ReadFile(filepath.Join(root, "docker-compose.yml")) + require.NoError(t, err) + var compose composeRuntimeDocument + require.NoError(t, yaml.Unmarshal(content, &compose), "docker-compose.yml must be valid YAML") + require.NotEmpty(t, compose.Services) + return compose +} + +func serviceCommandText(service composeRuntimeService) string { + return strings.TrimSpace(stringifyYAMLValue(service.Entrypoint) + "\n" + stringifyYAMLValue(service.Command)) +} + +func stringifyYAMLValue(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return typed + case []any: + parts := make([]string, 0, len(typed)) + for _, item := range typed { + parts = append(parts, stringifyYAMLValue(item)) + } + return strings.Join(parts, " ") + default: + return fmt.Sprint(typed) + } +} + +func dependencyCondition(raw any, dependency string) string { + switch typed := raw.(type) { + case []any: + for _, candidate := range typed { + if fmt.Sprint(candidate) == dependency { + return "service_started" + } + } + case map[string]any: + value, exists := typed[dependency] + if !exists { + return "" + } + if details, ok := value.(map[string]any); ok { + return fmt.Sprint(details["condition"]) + } + return "service_started" + } + return "" +} + +func publishedHostPort(raw any) (string, bool) { + if details, ok := raw.(map[string]any); ok { + published := strings.TrimSpace(fmt.Sprint(details["published"])) + return published, published != "" && published != "" + } + short := strings.TrimSpace(fmt.Sprint(raw)) + short = strings.TrimSuffix(strings.TrimSuffix(short, "/tcp"), "/udp") + lastColon := strings.LastIndex(short, ":") + if lastColon < 0 { + return "", false + } + hostSide := short[:lastColon] + if strings.HasPrefix(hostSide, "${") { + return hostSide, true + } + if hostColon := strings.LastIndex(hostSide, ":"); hostColon >= 0 { + hostSide = hostSide[hostColon+1:] + } + hostSide = strings.Trim(hostSide, "[]") + return hostSide, hostSide != "" +} + +func findVolumeMount(rawMounts []any, target string) (source string, readOnly bool, found bool) { + normalizedTarget := strings.TrimRight(target, "/") + for _, raw := range rawMounts { + if details, ok := raw.(map[string]any); ok { + if strings.TrimRight(fmt.Sprint(details["target"]), "/") != normalizedTarget { + continue + } + readOnly, _ = details["read_only"].(bool) + return fmt.Sprint(details["source"]), readOnly, true + } + + short := fmt.Sprint(raw) + parts := strings.Split(short, ":") + if len(parts) < 2 || strings.TrimRight(parts[1], "/") != normalizedTarget { + continue + } + for _, option := range parts[2:] { + if option == "ro" { + readOnly = true + } + } + return parts[0], readOnly, true + } + return "", false, false +} + +func environmentValue(raw any, name string) (string, bool) { + switch typed := raw.(type) { + case map[string]any: + value, exists := typed[name] + if !exists { + return "", false + } + return fmt.Sprint(value), true + case []any: + prefix := name + "=" + for _, item := range typed { + candidate := fmt.Sprint(item) + if strings.HasPrefix(candidate, prefix) { + return strings.TrimPrefix(candidate, prefix), true + } + } + } + return "", false +} + +func odbObjectTables(t testing.TB, root string) []string { + t.Helper() + paths, err := filepath.Glob(filepath.Join(root, "odb/*.hxx")) + require.NoError(t, err) + require.NotEmpty(t, paths) + objectPragma := regexp.MustCompile(`#pragma\s+db\s+object\s+table\("([A-Za-z_][A-Za-z0-9_]*)"\)`) + set := make(map[string]struct{}) + for _, path := range paths { + content, err := os.ReadFile(path) + require.NoError(t, err) + for _, match := range objectPragma.FindAllStringSubmatch(string(content), -1) { + set[match[1]] = struct{}{} + } + } + tables := make([]string, 0, len(set)) + for table := range set { + tables = append(tables, table) + } + sort.Strings(tables) + return tables +} + +func migrationTableContracts(t testing.TB, root string) map[string]bool { + t.Helper() + paths, err := filepath.Glob(filepath.Join(root, "sql/*.sql")) + require.NoError(t, err) + require.NotEmpty(t, paths, "sql/ must contain versioned migrations") + versionedName := regexp.MustCompile(`^V[0-9]+__[A-Za-z0-9_.-]+\.sql$`) + createTable := regexp.MustCompile("(?i)CREATE\\s+TABLE\\s+(IF\\s+NOT\\s+EXISTS\\s+)?(?:\\x60?[A-Za-z_][A-Za-z0-9_]*\\x60?\\.)?\\x60?([A-Za-z_][A-Za-z0-9_]*)\\x60?") + contracts := make(map[string]bool) + for _, path := range paths { + require.Regexp(t, versionedName, filepath.Base(path), + "migration filenames must be versioned") + content, err := os.ReadFile(path) + require.NoError(t, err) + for _, match := range createTable.FindAllStringSubmatch(string(content), -1) { + idempotent := strings.TrimSpace(match[1]) != "" + if previous, exists := contracts[match[2]]; exists { + contracts[match[2]] = previous && idempotent + } else { + contracts[match[2]] = idempotent + } + } + } + return contracts +} + +func declaresBoundedDeadline(source string) bool { + boundDeclaration := regexp.MustCompile(`(?im)\b[A-Z][A-Z0-9_]*(?:TIMEOUT|DEADLINE|MAX_ATTEMPTS|MAX_RETRIES)[A-Z0-9_]*\s*=`) + boundEnforcement := regexp.MustCompile(`(?im)(date\s+\+%s|\bSECONDS\b|\btimeout\b|\battempts?\b|\bretr(?:y|ies)\b)`) + return boundDeclaration.MatchString(source) && boundEnforcement.MatchString(source) +} + +func isLoopbackHostname(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/transmite/source/transmite_server.cc b/transmite/source/transmite_server.cc index 2cca554..f5fbaa2 100644 --- a/transmite/source/transmite_server.cc +++ b/transmite/source/transmite_server.cc @@ -30,7 +30,7 @@ DEFINE_bool(redis_keep_alive, true, "Redis 长连接"); DEFINE_int32(redis_pool_size, 8, "Redis 连接池大小"); DEFINE_string(mq_user, "root", "消息队列服务器访问用户名"); -DEFINE_string(mq_host, "127.0.0.1:5672", "消息队列服务器访问地址"); +DEFINE_string(mq_host, "127.0.0.1", "RabbitMQ hostname (port is fixed at 5672)"); // publisher-only:exchange 必须与 message 服务 mq_msg_exchange 一致 DEFINE_string(mq_msg_exchange, "chat_msg_exchange", "持久化消息的发布交换机名称(FANOUT,必须与 message.mq_msg_exchange 完全一致)"); DEFINE_string(mq_msg_queue, "", "publisher-only:留空,避免声明孤儿队列"); diff --git a/transmite/source/transmite_server.h b/transmite/source/transmite_server.h index 86130e7..75e5691 100644 --- a/transmite/source/transmite_server.h +++ b/transmite/source/transmite_server.h @@ -891,7 +891,7 @@ class TransmiteServerBuilder } _exchange_name = exchange_name; _routing_key.clear(); // publisher-only:FANOUT 路由忽略 routing_key,固定空串 - std::string amqp_url = "amqp://" + user + ":" + password + "@" + host + ":5672/"; + std::string amqp_url = make_amqp_url(user, password, host); _mq_client = std::make_shared(amqp_url); declare_settings settings { .exchange = exchange_name, From 3edb838549615267a6c9394ff659c173a915984a Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 22 Jul 2026 10:50:52 +0000 Subject: [PATCH 2/2] test(compose): format runtime contract --- tests/pkg/contracts/compose_runtime_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/pkg/contracts/compose_runtime_test.go b/tests/pkg/contracts/compose_runtime_test.go index da57091..44ab9ca 100644 --- a/tests/pkg/contracts/compose_runtime_test.go +++ b/tests/pkg/contracts/compose_runtime_test.go @@ -21,16 +21,16 @@ type composeRuntimeDocument struct { } type composeRuntimeService struct { - Build any `yaml:"build"` - Command any `yaml:"command"` - Entrypoint any `yaml:"entrypoint"` - DependsOn any `yaml:"depends_on"` - Environment any `yaml:"environment"` - Healthcheck any `yaml:"healthcheck"` + Build any `yaml:"build"` + Command any `yaml:"command"` + Entrypoint any `yaml:"entrypoint"` + DependsOn any `yaml:"depends_on"` + Environment any `yaml:"environment"` + Healthcheck any `yaml:"healthcheck"` Hostname string `yaml:"hostname"` - Ports []any `yaml:"ports"` - Restart any `yaml:"restart"` - Volumes []any `yaml:"volumes"` + Ports []any `yaml:"ports"` + Restart any `yaml:"restart"` + Volumes []any `yaml:"volumes"` } // Repository contract | P0 | The root Compose topology includes usable object storage.