diff --git a/docker/compose.yaml b/docker/compose.yaml index 9d27448a..54bcd323 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -25,8 +25,8 @@ services: CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}" CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}" ports: - - "3000:3000" - - "8317:8317" + - "${CCS_DASHBOARD_PORT:-3000}:3000" + - "${CCS_CLIPROXY_PORT:-8317}:8317" volumes: # /root/.ccs matches the HOME used inside the integrated image. # entrypoint-integrated.sh runs as root (supervisord user=root) and diff --git a/tests/docker/network-contract-env.test.sh b/tests/docker/network-contract-env.test.sh new file mode 100644 index 00000000..523a8b3c --- /dev/null +++ b/tests/docker/network-contract-env.test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Unit test for network-contract.sh Docker Compose env and args. +# Uses a fake docker binary; no Docker daemon required. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="${SCRIPT_DIR}/network-contract.sh" + +PASS=0 +FAIL=0 +MOCK_DIR="$(mktemp -d)" +LOG_FILE="${MOCK_DIR}/docker.log" +COMPOSE_FILE="${MOCK_DIR}/compose.yaml" +trap 'rm -rf "$MOCK_DIR"' EXIT + +cat > "$COMPOSE_FILE" <<'YAML' +services: + ccs: + image: ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest} +YAML + +cat > "${MOCK_DIR}/docker" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail + +printf 'cmd=%s env_image=%s env_dashboard=%s env_cliproxy=%s args=%s\n' \ + "$1" "${CCS_IMAGE:-}" "${CCS_DASHBOARD_PORT:-}" "${CCS_CLIPROXY_PORT:-}" "$*" \ + >> "$DOCKER_MOCK_LOG" + +if [[ "$1" == "compose" ]]; then + if printf '%s\n' "$*" | grep -q 'ps --format json'; then + printf '[{"Service":"ccs","Health":"healthy"}]\n' + fi + exit 0 +fi + +if [[ "$1" == "network" && "$2" == "inspect" && "$3" == "ccs-net" ]]; then + exit 0 +fi + +if [[ "$1" == "run" ]]; then + exit 0 +fi + +exit 1 +MOCK +chmod +x "${MOCK_DIR}/docker" + +run_test() { + local name="$1" + shift + + if "$@"; then + echo "[OK] ${name}" + (( PASS++ )) || true + else + echo "[X] ${name}" + (( FAIL++ )) || true + fi +} + +run_contract() { + PATH="${MOCK_DIR}:${PATH}" \ + DOCKER_MOCK_LOG="$LOG_FILE" \ + bash "$SCRIPT" "$COMPOSE_FILE" "ghcr.io/kaitranntt/ccs:test" >/dev/null +} + +echo "" +echo "Running network-contract.sh env tests..." +echo "" + +run_contract + +run_test "compose up receives safe host ports and image override" \ + grep -q 'env_image=ghcr.io/kaitranntt/ccs:test env_dashboard=13001 env_cliproxy=18318 args=compose -f .* up -d --remove-orphans' "$LOG_FILE" + +run_test "compose ps receives the same safe host ports" \ + grep -q 'env_image=ghcr.io/kaitranntt/ccs:test env_dashboard=13001 env_cliproxy=18318 args=compose -f .* ps --format json' "$LOG_FILE" + +run_test "compose down removes volumes and orphans through cleanup trap" \ + grep -q 'env_image=ghcr.io/kaitranntt/ccs:test env_dashboard=13001 env_cliproxy=18318 args=compose -f .* down -v --remove-orphans' "$LOG_FILE" + +run_test "sibling probes keep the ccs-net DNS contract" \ + grep -q 'args=run --rm --network ccs-net curlimages/curl:latest -fsS --max-time 10 http://ccs:8317/' "$LOG_FILE" + +run_test "dashboard sibling probe keeps internal port 3000" \ + grep -q 'args=run --rm --network ccs-net curlimages/curl:latest -fsS --max-time 10 http://ccs:3000/' "$LOG_FILE" + +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "" + +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi diff --git a/tests/docker/network-contract.sh b/tests/docker/network-contract.sh index 6e5aa4b1..ec31b13f 100755 --- a/tests/docker/network-contract.sh +++ b/tests/docker/network-contract.sh @@ -19,6 +19,8 @@ set -euo pipefail COMPOSE_FILE="${1:-docker/compose.yaml}" IMAGE_OVERRIDE="${2:-}" +DASHBOARD_HOST_PORT="${CCS_NETWORK_CONTRACT_DASHBOARD_PORT:-${CCS_DASHBOARD_PORT:-13001}}" +CLIPROXY_HOST_PORT="${CCS_NETWORK_CONTRACT_CLIPROXY_PORT:-${CCS_CLIPROXY_PORT:-18318}}" # --------------------------------------------------------------------------- # Helpers @@ -27,22 +29,35 @@ log() { printf '[i] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } err() { printf '[X] %s\n' "$*" >&2; } +compose() { + if [[ -n "$IMAGE_OVERRIDE" ]]; then + CCS_IMAGE="$IMAGE_OVERRIDE" \ + CCS_DASHBOARD_PORT="$DASHBOARD_HOST_PORT" \ + CCS_CLIPROXY_PORT="$CLIPROXY_HOST_PORT" \ + docker compose -f "$COMPOSE_FILE" "$@" + return + fi + + CCS_DASHBOARD_PORT="$DASHBOARD_HOST_PORT" \ + CCS_CLIPROXY_PORT="$CLIPROXY_HOST_PORT" \ + docker compose -f "$COMPOSE_FILE" "$@" +} + +cleanup() { + log "Tearing down stack..." + compose down -v --remove-orphans 2>/dev/null || true +} +trap cleanup EXIT + # --------------------------------------------------------------------------- # Bring stack up; register teardown on any exit # --------------------------------------------------------------------------- log "Bringing CCS stack up: $COMPOSE_FILE" +log "Using host ports: dashboard=${DASHBOARD_HOST_PORT}, cliproxy=${CLIPROXY_HOST_PORT}" if [[ -n "$IMAGE_OVERRIDE" ]]; then log "Overriding image with: $IMAGE_OVERRIDE" - CCS_IMAGE="$IMAGE_OVERRIDE" docker compose -f "$COMPOSE_FILE" up -d -else - docker compose -f "$COMPOSE_FILE" up -d fi - -cleanup() { - log "Tearing down stack..." - docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true -} -trap cleanup EXIT +compose up -d --remove-orphans # --------------------------------------------------------------------------- # Wait for healthcheck (max 90s) — use jq instead of python3 for CI portability @@ -52,7 +67,7 @@ WAIT_MAX=45 # 45 x 2s = 90s HEALTHY=0 for _i in $(seq 1 "$WAIT_MAX"); do STATUS=$( - docker compose -f "$COMPOSE_FILE" ps --format json 2>/dev/null \ + compose ps --format json 2>/dev/null \ | jq -r 'if type == "array" then .[] else . end | select(.Service != null and (.Service | contains("ccs"))) | .Health // "unknown"' \ 2>/dev/null | head -1 || echo "unknown" ) diff --git a/tests/unit/docker/docker-release-workflow-context.test.ts b/tests/unit/docker/docker-release-workflow-context.test.ts index 2ede55f7..8a5ba0c3 100644 --- a/tests/unit/docker/docker-release-workflow-context.test.ts +++ b/tests/unit/docker/docker-release-workflow-context.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -43,4 +44,24 @@ describe('docker release workflow context', () => { expect(dockerfile).toContain('127.0.0.1:3000'); expect(dockerfile).toContain('127.0.0.1:8317'); }); + + test('lets network-contract smoke tests avoid fixed host port collisions', () => { + const compose = readFileSync(join(repoRoot, 'docker/compose.yaml'), 'utf8'); + const contractScript = readFileSync(join(repoRoot, 'tests/docker/network-contract.sh'), 'utf8'); + + expect(compose).toContain('${CCS_DASHBOARD_PORT:-3000}:3000'); + expect(compose).toContain('${CCS_CLIPROXY_PORT:-8317}:8317'); + expect(contractScript).toContain('CCS_NETWORK_CONTRACT_DASHBOARD_PORT:-${CCS_DASHBOARD_PORT:-13001}'); + expect(contractScript).toContain('CCS_NETWORK_CONTRACT_CLIPROXY_PORT:-${CCS_CLIPROXY_PORT:-18318}'); + expect(contractScript).toContain('up -d --remove-orphans'); + }); + + test('passes collision-safe compose env through network-contract calls', () => { + const result = spawnSync('bash', ['tests/docker/network-contract-env.test.sh'], { + cwd: repoRoot, + encoding: 'utf8', + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + }); });