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
+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__":