Run PostgreSQL and Cassandra integration tests and deploy Compose stack
Docker test deployment / test-build-deploy (push) Failing after 8m35s

This commit is contained in:
2026-09-16 03:51:34 +08:00
parent 789ab64013
commit 7fa2609191
10 changed files with 241 additions and 20 deletions
+8 -2
View File
@@ -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
+2 -1
View File
@@ -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
+2
View File
@@ -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 部署文档。
+96
View File
@@ -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}
+19 -13
View File
@@ -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-<commit>-<run>` 独立项目;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 故障。
验证宿主机端口时,临时探测容器挂载配置卷为只读,读取凭据在进程内发送,不将凭据放到命令参数或日志中。
公网健康检查:
+70
View File
@@ -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).'
+20
View File
@@ -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()
+20 -2
View File
@@ -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__":
+1 -1
View File
@@ -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):
+3 -1
View File
@@ -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,