From 7fa26091917b9ca0496adedb68e0efc3495621a5 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 16 Sep 2026 03:51:34 +0800 Subject: [PATCH] Run PostgreSQL and Cassandra integration tests and deploy Compose stack --- .gitea/workflows/docker-deploy.yml | 10 ++- Dockerfile | 3 +- README.md | 2 + compose.yml | 96 ++++++++++++++++++++++ docs/docker-deployment.md | 32 +++++--- scripts/compose_stack.sh | 70 ++++++++++++++++ scripts/init_cassandra.py | 20 +++++ scripts/smoke_api.py | 22 ++++- src/adcloud_server/container_entrypoint.py | 2 +- tests/test_cassandra.py | 4 +- 10 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 compose.yml create mode 100755 scripts/compose_stack.sh create mode 100644 scripts/init_cassandra.py diff --git a/.gitea/workflows/docker-deploy.yml b/.gitea/workflows/docker-deploy.yml index 0467022..3086835 100644 --- a/.gitea/workflows/docker-deploy.yml +++ b/.gitea/workflows/docker-deploy.yml @@ -27,7 +27,7 @@ jobs: SOURCE_REPOSITORY: ${{ gitea.repository }} SOURCE_REVISION: ${{ gitea.sha }} DEPLOY_PORT: ${{ inputs.deploy_port || vars.ADCLOUD_DEPLOY_PORT || '18080' }} - PUBLIC_BASE_URL: ${{ inputs.public_base_url || vars.ADCLOUD_PUBLIC_BASE_URL || '' }} + PUBLIC_BASE_URL: ${{ inputs.public_base_url || vars.ADCLOUD_PUBLIC_BASE_URL || 'http://47.76.92.159:18080' }} steps: - name: Check Docker daemon and tools run: | @@ -68,12 +68,18 @@ jobs: - name: Build the runtime image run: | docker build --target runtime --tag "adcloud-server-ci:$SOURCE_REVISION" --label "org.opencontainers.image.revision=$SOURCE_REVISION" . + - name: Test PostgreSQL and Cassandra integration + run: | + export DEPLOY_IMAGE="adcloud-server-ci:$SOURCE_REVISION" + export TEST_IMAGE="adcloud-server-ci:$SOURCE_REVISION-test" + export CI_STACK_NAME="adcloud-ci-$(printf '%s' "$SOURCE_REVISION" | cut -c1-12)-${{ gitea.run_id }}" + bash scripts/compose_stack.sh ci - name: Deploy and verify published host port run: | export DEPLOY_IMAGE="adcloud-server-ci:$SOURCE_REVISION" export DEPLOY_REPOSITORY="$SOURCE_REPOSITORY" export DEPLOY_REVISION="$SOURCE_REVISION" - bash scripts/deploy_docker.sh + bash scripts/compose_stack.sh deploy - name: Probe configured public URL run: | set -eu diff --git a/Dockerfile b/Dockerfile index b803605..f574f6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,8 @@ RUN groupadd --gid 10001 adcloud && useradd --uid 10001 --gid adcloud --no-creat && mkdir -p /app/scripts /data /config \ && chown -R adcloud:adcloud /app /data /config && chmod 700 /data /config COPY --from=build /opt/venv /opt/venv -COPY scripts/smoke_api.py /app/scripts/smoke_api.py +COPY scripts/smoke_api.py scripts/init_cassandra.py /app/scripts/ +COPY deploy/cassandra.cql /app/deploy/cassandra.cql WORKDIR /app USER 10001:10001 EXPOSE 8000 diff --git a/README.md b/README.md index d6682d4..7cd56c2 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,5 @@ uv sync --locked --extra postgres --extra cassandra Dockerfile 提供 test、runtime 两个目标;Gitea Actions 在 main 推送后构建、测试并部署到 runner 的 Docker daemon,默认映射宿主机 18080 到容器 8000。 详细配置、runner 要求、公网端口、持久化和回滚见 [Docker 部署说明](docs/docker-deployment.md)。 + +测试部署使用 `compose.yml` 编排 PostgreSQL、Cassandra、API 与画像 worker;CI 运行全部数据库集成测试,公网 API 默认 http://47.76.92.159:18080/docs。详见 Docker 部署文档。 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..14cf7aa --- /dev/null +++ b/compose.yml @@ -0,0 +1,96 @@ +# Test stack only. Only the API publishes a host port. +x-app: &app + image: ${DEPLOY_IMAGE:?Set DEPLOY_IMAGE} + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + tmpfs: [/tmp:rw,noexec,nosuid,size=64m] + mem_limit: 512m + pids_limit: 128 + volumes: [config:/config] + environment: &app-env + DATABASE_URL: postgresql+psycopg://adcloud:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/adcloud + PROFILE_BACKEND: cassandra + CASSANDRA_HOSTS: cassandra + CASSANDRA_TLS: 'false' + COOKIE_SECURE: 'false' + CORS_ORIGINS: ${DEPLOY_CORS_ORIGINS:-http://localhost:3000,http://localhost:5173,http://localhost:5174} + depends_on: &app-deps + postgres: {condition: service_healthy} + cassandra-init: {condition: service_completed_successfully} + +services: + postgres: + image: postgres:16-bookworm + restart: unless-stopped + mem_limit: 512m + environment: + POSTGRES_DB: adcloud + POSTGRES_USER: adcloud + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + volumes: [postgres-data:/var/lib/postgresql/data] + healthcheck: + test: [CMD-SHELL, 'pg_isready -U adcloud -d adcloud'] + interval: 5s + timeout: 5s + retries: 30 + cassandra: + image: cassandra:4.1 + restart: unless-stopped + mem_limit: 2g + environment: + CASSANDRA_CLUSTER_NAME: AdcloudTest + CASSANDRA_DC: datacenter1 + CASSANDRA_ENDPOINT_SNITCH: GossipingPropertyFileSnitch + MAX_HEAP_SIZE: 768M + HEAP_NEWSIZE: 256M + volumes: [cassandra-data:/var/lib/cassandra] + healthcheck: + test: [CMD-SHELL, "cqlsh -e 'SELECT release_version FROM system.local' 127.0.0.1"] + start_period: 60s + interval: 10s + timeout: 10s + retries: 30 + cassandra-init: + image: ${DEPLOY_IMAGE:?Set DEPLOY_IMAGE} + entrypoint: [python, /app/scripts/init_cassandra.py] + mem_limit: 256m + depends_on: + cassandra: {condition: service_healthy} + api: + <<: *app + restart: unless-stopped + ports: ['${DEPLOY_BIND_IP:-0.0.0.0}:${DEPLOY_PORT:-18080}:8000'] + worker: + <<: *app + restart: unless-stopped + command: [adcloud-profiles] + healthcheck: + disable: true + integration: + image: ${TEST_IMAGE:?Set TEST_IMAGE} + profiles: [ci] + depends_on: *app-deps + environment: + ADCLOUD_TEST_POSTGRES_URL: postgresql+psycopg://adcloud:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/adcloud + ADCLOUD_TEST_CASSANDRA_HOSTS: cassandra + ADCLOUD_TEST_CASSANDRA_KEYSPACE: adcloud + ADCLOUD_TEST_CASSANDRA_TLS: 'false' + smoke: + <<: *app + profiles: [checks] + entrypoint: [python, /app/scripts/smoke_api.py] + command: [--base-url, 'http://api:8000', --require-projection] + volumes: [config:/config:ro] + depends_on: + api: {condition: service_healthy} + worker: {condition: service_started} + healthcheck: + disable: true + +volumes: + postgres-data: {} + cassandra-data: {} + config: + name: ${CONFIG_VOLUME:-${COMPOSE_PROJECT_NAME:-adcloud-test}-config} + external: ${CONFIG_EXTERNAL:-false} diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index 6d44119..f156c4e 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -8,8 +8,8 @@ 2. 使用 Gitea 自动签发的短期作业 token 下载精确提交的源码,不将认证信息放入 URL、命令行或日志。 3. 用 uv 和 uv.lock 构建 test 镜像,执行 Ruff 与 pytest。外部数据库测试未配置环境时跳过。 4. 构建不含开发依赖的非 root runtime 镜像,包含 PostgreSQL / Cassandra Python 驱动,允许以后配置存储;不运行数据库服务。 -5. 在一次性 candidate 容器上验证真实 HTTP 流程。 -6. 替换测试容器,持久化数据和密钥,映射宿主机端口,再从 Docker daemon 的 host 网络验证请求。 +5. 通过独立 Compose 项目启动临时 PostgreSQL / Cassandra,执行全部 54 个测试(包含 6 个真实数据库测试);结束后清理该 CI 项目的容器和数据卷。 +6. 用 Compose 部署持久化 PostgreSQL / Cassandra、API 和画像 worker,检查 JSONB 存储、画像投影与宿主机 HTTP 端口。 7. 如提供公网 URL,再探测公网 `/health`。后续可从外部执行匿名和埋点测试。 Docker 镜像直接保存在该 daemon 上,本版不依赖镜像仓库登录。 @@ -65,27 +65,33 @@ Docker socket 具有管理 daemon 的能力,因此部署工作流仅对 main ```sh docker build --target runtime --tag adcloud-server:test . -DEPLOY_IMAGE=adcloud-server:test bash scripts/deploy_docker.sh +DEPLOY_IMAGE=adcloud-server:test bash scripts/compose_stack.sh deploy ``` -当前固定测试部署配置为 SQLite / relational。未来连接数据库时需给容器传入 DATABASE_URL 等配置,并按需要启动画像 worker;本工作流不创建 PostgreSQL 或 Cassandra。 +当前公网环境使用 PostgreSQL 16 保存身份图谱和 JSONB 权威画像,Cassandra 4.1 保存画像投影,worker 持续消费 outbox。数据库不发布宿主机端口,只能通过项目 Docker 内网连接。 +Cassandra 在此合成测试环境内关闭 TLS / 认证,单节点 RF=1;生产环境需另外配置认证、TLS、备份和多节点副本。 +数据库和应用合计内存限制约 3.5 GiB,服务器应留出至少 4 GiB 可用内存,首次启动 Cassandra 可能需要几分钟。 +`compose.yml` 使用健康检查和一次性 CQL 初始化任务控制启动顺序。 + +临时 CI 使用 `adcloud-ci--` 独立项目;PostgreSQL 密码随机生成。Cassandra 测试同时连接 CI PostgreSQL,验证双存储组合。失败时也会清理该项目。 + +手动 Compose 操作需通过脚本加载 config 卷中持久化的 PostgreSQL 密码;不要直接输出 `docker compose config`(其中含展开的连接凭据)。 ## 持久化、权限和回滚 -- `adcloud-server-test-data`:SQLite 数据,挂到 `/data`。 -- `adcloud-server-test-config`:运行密钥,挂到 `/config`。 -- 首次启动生成随机 IDENTITY_SECRET、SERVICE_TOKEN,原子写入 mode 0600 的配置文件;后续重建复用它们,不输出到流水线日志。 -- runtime 使用 UID/GID 10001,根文件系统只读,去除 capabilities,启用 no-new-privileges,并限制 CPU、内存和进程数。 -- 正式测试容器使用 `restart=unless-stopped`,Actions 作业结束后继续运行。 -- 只替换具有本项目 managed/repository 标签的容器和 volume,不删除其他服务或数据卷。 -- named lock 防止多个部署同时操作测试容器。取消任务可能遗留 lock;先确认无部署仍在运行,再由服务器管理员检查并清理 `adcloud-server-test-deploy-lock`。 -- 新容器健康检查或 host-port HTTP 验证失败时,删除失败的新容器并尽可能恢复旧容器。端口替换期间有短暂停机;不保证数据库 schema 迁移可以回滚。 -- 本版不自动删除旧 Docker 镜像,避免影响其他构建;后续需按保留策略清理本项目镜像。 +- Compose 项目为 `adcloud-test`;PostgreSQL 和 Cassandra 分别使用项目专属命名数据卷。 +- `adcloud-server-test-config` 复用原身份密钥卷;首次补充随机 PostgreSQL 密码,重部署复用,配置文件权限 0600,不输出到日志。 +- API / worker 使用 UID/GID 10001、只读根文件系统、去除 capabilities、限制内存和进程;均使用 `restart=unless-stopped`。 +- named lock 防止并发部署。确认无部署仍在运行后才能清理遗留的 `adcloud-server-test-deploy-lock`。 +- 首次切换前先等待数据库就绪;旧 SQLite 容器仅停止,旧数据卷保留,无自动数据迁移。 +- 首次 Compose 切换失败时尽可能恢复旧 SQLite 容器;后续 Compose 更新不是原子部署,不保证 schema 迁移可回滚。公网 API 切换有短暂停机。 +- 测试部署不自动清除持久化数据卷或旧镜像。原 `deploy_docker.sh` 保留用于独立 SQLite 部署,不与 Compose 同时运行。 ## HTTP 验证 `smoke_api.py` 检查系统/模块路由、匿名 Cookie、埋点、未授权拒绝、可信 Stitch、画像继承和历史事件回溯。 使用合成邮箱与独立匿名身份,会向测试数据库写入少量演示数据。 +`--require-projection` 还会验证 PostgreSQL / JSONB 配置,并等待画像状态变为 Cassandra `current`,避免降级到关系型读取掩盖 worker 故障。 验证宿主机端口时,临时探测容器挂载配置卷为只读,读取凭据在进程内发送,不将凭据放到命令参数或日志中。 公网健康检查: diff --git a/scripts/compose_stack.sh b/scripts/compose_stack.sh new file mode 100755 index 0000000..2e380bd --- /dev/null +++ b/scripts/compose_stack.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +: "${DEPLOY_IMAGE:?DEPLOY_IMAGE is required}" +export TEST_IMAGE=${TEST_IMAGE:-$DEPLOY_IMAGE} +mode=${1:-deploy} +docker compose version +if [[ "$mode" == ci ]]; then + : "${CI_STACK_NAME:?CI_STACK_NAME is required}" + [[ "$CI_STACK_NAME" =~ ^adcloud-ci-[a-z0-9-]+$ ]] || exit 2 + export COMPOSE_PROJECT_NAME=$CI_STACK_NAME + export POSTGRES_PASSWORD + POSTGRES_PASSWORD=$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))') + unset CONFIG_VOLUME CONFIG_EXTERNAL + # Only this unique disposable CI project is removed, including its volumes. + trap 'docker compose -f compose.yml --profile ci down --volumes --remove-orphans >/dev/null' EXIT + docker compose -f compose.yml --profile ci run --rm integration + exit +fi +[[ "$mode" == deploy ]] || { echo 'Use ci or deploy'; exit 2; } +export DEPLOY_PORT=${DEPLOY_PORT:-18080} +[[ "$DEPLOY_PORT" =~ ^[0-9]{1,5}$ ]] && ((10#$DEPLOY_PORT >= 1024 && 10#$DEPLOY_PORT <= 65535)) || exit 2 +export COMPOSE_PROJECT_NAME=adcloud-test +export CONFIG_VOLUME=adcloud-server-test-config CONFIG_EXTERNAL=true +lock=adcloud-server-test-deploy-lock +lock_acquired=false +legacy_stopped=false +successful=false +cleanup() { + local result=$? + trap - EXIT + if ! $successful && $legacy_stopped; then + docker compose -f compose.yml stop api worker >/dev/null || true + docker start adcloud-server-test >/dev/null || true + echo 'Compose deployment failed; legacy SQLite service restored where possible.' + fi + if $lock_acquired; then docker rm "$lock" >/dev/null || true; fi + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +for attempt in $(seq 1 60); do + if docker create --name "$lock" --label io.entroia.adcloud.managed=true --label io.entroia.adcloud.repository=Zayden-Jung/adcloud-server --entrypoint /bin/true "$DEPLOY_IMAGE" >/dev/null 2>&1; then + lock_acquired=true; break + fi + sleep 2 +done +$lock_acquired || { echo 'Deployment lock unavailable'; exit 1; } +if ! docker volume inspect "$CONFIG_VOLUME" >/dev/null 2>&1; then + docker volume create --label io.entroia.adcloud.repository=Zayden-Jung/adcloud-server "$CONFIG_VOLUME" >/dev/null +fi +[[ "$(docker volume inspect --format '{{index .Labels "io.entroia.adcloud.repository"}}' "$CONFIG_VOLUME")" == Zayden-Jung/adcloud-server ]] || { echo 'Refusing unmanaged config volume'; exit 1; } +# Capture a persistent random password in memory; never echo it or use shell tracing. +export POSTGRES_PASSWORD +POSTGRES_PASSWORD=$(docker run --rm --volume "$CONFIG_VOLUME:/config" --entrypoint python "$DEPLOY_IMAGE" -c 'from pathlib import Path; from adcloud_server.container_entrypoint import bootstrap; print(bootstrap(Path("/config/identity.env"))["POSTGRES_PASSWORD"])') +# Initialize databases before switching the public API port. +docker compose -f compose.yml up -d postgres cassandra +docker compose -f compose.yml run --rm cassandra-init +if docker container inspect adcloud-server-test >/dev/null 2>&1; then + [[ "$(docker inspect --format '{{index .Config.Labels "io.entroia.adcloud.repository"}}' adcloud-server-test)" == Zayden-Jung/adcloud-server ]] || { echo 'Refusing unmanaged legacy container'; exit 1; } + docker stop --time 20 adcloud-server-test >/dev/null + legacy_stopped=true +fi +docker compose -f compose.yml up -d --wait --wait-timeout 180 api worker +docker compose -f compose.yml --profile checks run --rm smoke +# Verify the published host port too; config stays read-only in this probe. +docker run --rm --network host --volume "$CONFIG_VOLUME:/config:ro" --entrypoint python "$DEPLOY_IMAGE" /app/scripts/smoke_api.py --base-url "http://127.0.0.1:$DEPLOY_PORT" --require-projection +successful=true +echo "PostgreSQL + Cassandra + worker deployed; public API port=$DEPLOY_PORT" +echo 'Legacy SQLite container remains stopped; its data volume is retained (no automatic data migration).' diff --git a/scripts/init_cassandra.py b/scripts/init_cassandra.py new file mode 100644 index 0000000..056bcd2 --- /dev/null +++ b/scripts/init_cassandra.py @@ -0,0 +1,20 @@ +"""Initialize the single-datacenter test cluster using the checked-in CQL schema.""" + +import os +from pathlib import Path + +from cassandra.cluster import Cluster + +cluster = Cluster([os.getenv("CASSANDRA_HOST", "cassandra")], connect_timeout=10) +try: + session = cluster.connect() + schema = Path("/app/deploy/cassandra.cql").read_text() + schema = "\n".join( + line for line in schema.splitlines() if not line.startswith("--") + ) + for statement in schema.split(";"): + if statement.strip(): + session.execute(statement, timeout=30) + print("Cassandra test schema initialized") +finally: + cluster.shutdown() diff --git a/scripts/smoke_api.py b/scripts/smoke_api.py index a9b25ba..8188220 100644 --- a/scripts/smoke_api.py +++ b/scripts/smoke_api.py @@ -4,6 +4,7 @@ import argparse import http.cookiejar import json import os +import time import urllib.error import urllib.request from pathlib import Path @@ -12,7 +13,7 @@ from uuid import uuid4 from dotenv import dotenv_values -def check(base_url: str, service_token: str): +def check(base_url: str, service_token: str, require_projection=False): jar = http.cookiejar.CookieJar() opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) @@ -78,6 +79,20 @@ def check(base_url: str, service_token: str): assert history["items"][0]["id"] == event["event_id"] graph = api("/v1/identity/" + linked["master_id"] + "/graph", trusted=True) assert len(graph["identifiers"]) == 1 + if require_projection: + storage = api("/v1/identity/storage", trusted=True) + assert storage["graph_backend"] == "postgresql" + assert storage["canonical_profile_format"] == "jsonb" + assert storage["profile_backend"] == "cassandra" + for _ in range(60): + profile = api("/v1/users/" + linked["master_id"], trusted=True) + if profile["projection_status"] == "current": + break + time.sleep(1) + assert profile["projection_status"] == "current", ( + "Worker projection not current" + ) + assert profile["attributes"]["smoke"] is True print( "HTTP smoke passed: routes, anonymous cookie, events" + (", auth, stitching, profile and history" if service_token else "") @@ -92,6 +107,7 @@ def main(): action="store_true", help="Do not read or send the service token", ) + parser.add_argument("--require-projection", action="store_true") args = parser.parse_args() token = "" if not args.public_only: @@ -103,7 +119,9 @@ def main(): raise SystemExit( "Configure SERVICE_TOKEN or mount the deployment config volume" ) - check(args.base_url, token) + if args.require_projection and args.public_only: + parser.error("Projection checks require a service token") + check(args.base_url, token, args.require_projection) if __name__ == "__main__": diff --git a/src/adcloud_server/container_entrypoint.py b/src/adcloud_server/container_entrypoint.py index 4c88399..44ff612 100644 --- a/src/adcloud_server/container_entrypoint.py +++ b/src/adcloud_server/container_entrypoint.py @@ -10,7 +10,7 @@ from pathlib import Path from dotenv import dotenv_values -SECRET_NAMES = ("IDENTITY_SECRET", "SERVICE_TOKEN") +SECRET_NAMES = ("IDENTITY_SECRET", "SERVICE_TOKEN", "POSTGRES_PASSWORD") def bootstrap(path: Path): diff --git a/tests/test_cassandra.py b/tests/test_cassandra.py index 7ba0bf0..f1e2e72 100644 --- a/tests/test_cassandra.py +++ b/tests/test_cassandra.py @@ -21,7 +21,9 @@ pytestmark = pytest.mark.skipif( @pytest.fixture def components(tmp_path): settings = Settings( - database_url="sqlite:///" + str(tmp_path / "graph.db"), + database_url=os.getenv( + "ADCLOUD_TEST_POSTGRES_URL", "sqlite:///" + str(tmp_path / "graph.db") + ), profile_backend="cassandra", tenant_id="test-" + str(uuid4()), identity_secret="test-cassandra-" + "x" * 48,