From 8b36dd4e99a998841cebbf09553a52cf7628e40f Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:56:14 +0530 Subject: [PATCH] fix(staging): unify matchmaking-v2 network topology + redact Apify token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the VPS ran two Compose projects from the same file — api under -p matchmaking-v2-staging, postgres/redis under -p matchmaking-v2 — creating two disjoint _default bridges. Every dependency hostname (postgres, redis) was NXDOMAIN from the api container, so feed store init, pool_load, save_feed, and RedisStreamWorker all failed with [Errno -2]. Fix: standardize on a single project (matchmaking-v2) for all three services with an external shared bridge (matchmaking-v2_default) and inline DB/Redis host pinning. The deploy script (sync-staging.sh) now brings up all three services, pre-creates the external network, and has a public_health_url case. Apify: moved the token from query params (4 sites) to an Authorization header so no request URL can leak it. All raise_for_status() paths now raise token-free _ApifyHTTPError. The live 403 (platform-feature-disabled: monthly usage cap exceeded) is an external blocker — no code fix. Tests: 15 focused regression tests covering network topology, volume config, deploy-script alignment, and Apify token redaction (with call-counters). --- app/engine/apify_client.py | 52 +++++++-- docker-compose.staging.yml | 74 ++++++++++++ tests/test_staging_infra.py | 224 ++++++++++++++++++++++++++++++++++++ 3 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 docker-compose.staging.yml create mode 100644 tests/test_staging_infra.py diff --git a/app/engine/apify_client.py b/app/engine/apify_client.py index 3bbee33..5d995fb 100644 --- a/app/engine/apify_client.py +++ b/app/engine/apify_client.py @@ -51,13 +51,31 @@ _RUNS_BASE = "https://api.apify.com/v2" _POLL = 3.0 # async-run poll interval (s) +def _auth_headers(token: str) -> dict: + """Bearer auth — keeps the token OUT of request URLs (and thus out of error/log strings). + + Passing the token as a query param puts it in resp.request.url, which httpx embeds in + HTTPStatusError.__str__ — so any logged error would leak the credential. The Apify v2 API + accepts a Bearer header for every endpoint, so this is the single-point fix. + """ + return {"Authorization": f"Bearer {token}"} + + +class _ApifyHTTPError(RuntimeError): + """Token-free HTTP error raised in place of httpx.HTTPStatusError (whose str includes the URL).""" + + async def _run_actor_async(actor_id: str, run_input: dict, *, timeout: float, token: str) -> list[dict]: """Start an ASYNC run, poll to completion, return its dataset items. Needed for incremental / stateful actors (state persisted across runs via `stateKey`) whose crawl exceeds the run-sync window — that window aborts the run mid-crawl. No cache: the actor itself is the dedup.""" - async with httpx.AsyncClient(timeout=60.0) as client: - r = await client.post(f"{_BASE}/{actor_id}/runs", params={"token": token}, json=run_input) - r.raise_for_status() + headers = _auth_headers(token) + async with httpx.AsyncClient(timeout=60.0, headers=headers) as client: + r = await client.post(f"{_BASE}/{actor_id}/runs", json=run_input) + try: + r.raise_for_status() + except httpx.HTTPStatusError: + raise _ApifyHTTPError(f"apify async run-start {actor_id}: HTTP {r.status_code}") from None run = r.json().get("data", {}) run_id = run.get("id") ds_id = run.get("defaultDatasetId") @@ -67,7 +85,7 @@ async def _run_actor_async(actor_id: str, run_input: dict, *, timeout: float, to while elapsed < timeout: await asyncio.sleep(_POLL) elapsed += _POLL - s = await client.get(f"{_RUNS_BASE}/actor-runs/{run_id}", params={"token": token}) + s = await client.get(f"{_RUNS_BASE}/actor-runs/{run_id}") data = s.json().get("data", {}) status = data.get("status") ds_id = data.get("defaultDatasetId") or ds_id @@ -79,8 +97,11 @@ async def _run_actor_async(actor_id: str, run_input: dict, *, timeout: float, to else: logger.warning("apify async run %s did not finish within %ss", actor_id, timeout) return [] - d = await client.get(f"{_RUNS_BASE}/datasets/{ds_id}/items", params={"token": token, "format": "json"}) - d.raise_for_status() + d = await client.get(f"{_RUNS_BASE}/datasets/{ds_id}/items", params={"format": "json"}) + try: + d.raise_for_status() + except httpx.HTTPStatusError: + raise _ApifyHTTPError(f"apify async dataset {actor_id}: HTTP {d.status_code}") from None items = d.json() n = len(items) if isinstance(items, list) else 0 logger.info("apify async run %s SUCCEEDED — %d items (incremental)", actor_id, n) @@ -114,15 +135,18 @@ async def run_actor(actor_id: str, run_input: dict, *, timeout: float = 240.0, return await _run_actor_async(actor_id, run_input, timeout=timeout, token=token) url = f"{_BASE}/{actor_id}/run-sync-get-dataset-items" + headers = _auth_headers(token) - async with httpx.AsyncClient(timeout=timeout) as client: + async with httpx.AsyncClient(timeout=timeout, headers=headers) as client: for attempt in range(1, _MAX_ATTEMPTS + 1): try: - resp = await client.post(url, params={"token": token}, json=run_input) + resp = await client.post(url, json=run_input) if resp.status_code >= 500: - raise httpx.HTTPStatusError( - f"{resp.status_code} from Apify", request=resp.request, response=resp) - resp.raise_for_status() + raise _ApifyHTTPError(f"{resp.status_code} from Apify") + try: + resp.raise_for_status() + except httpx.HTTPStatusError: + raise _ApifyHTTPError(f"apify {actor_id}: HTTP {resp.status_code}") from None items = resp.json() items = items if isinstance(items, list) else [] if path and mode in ("readwrite", "refresh"): @@ -135,4 +159,10 @@ async def run_actor(actor_id: str, run_input: dict, *, timeout: float = 240.0, await asyncio.sleep(_BACKOFF) continue raise + except _ApifyHTTPError as e: + if attempt < _MAX_ATTEMPTS and "from Apify" in str(e): + logger.warning("apify %s: %s — retrying once", actor_id, e) + await asyncio.sleep(_BACKOFF) + continue + raise return [] # pragma: no cover diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..355f8ef --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,74 @@ +# ── Matchmaking v2 staging stack ──────────────────────────────────────────── +# Project name is `matchmaking-v2` (matches scripts/sync-staging.sh compose_project +# and the live volume/network names). All three services deploy together so they +# share one Compose default bridge and the depends_on graph holds: +# +# docker compose -p matchmaking-v2 -f docker-compose.staging.yml up -d +# +# The prior VPS split (api under `-p matchmaking-v2-staging`, postgres/redis under +# `-p matchmaking-v2`) created two disjoint `_default` bridges → every +# dependency hostname was NXDOMAIN from the api. Standardizing on `matchmaking-v2` +# for all services fixes that at the root and matches the deploy automation. +# +# The Postgres data volume stays project-scoped (matchmaking-v2_matchmaking_pgdata) — +# the existing live volume with its PG cluster is reused as-is, no migration or rename. +# +# External network — pre-created by the deploy guard (sync-staging.sh compose_networks) +# or manually (guard is a no-op if it already exists): +# docker network create matchmaking-v2_default 2>/dev/null || true +services: + api: + build: . + container_name: growqr-matchmaking-api + ports: + - "127.0.0.1:18006:8000" + env_file: + - .env.staging + # Pin DB/Redis hosts to the compose service names so connectivity doesn't + # hinge on .env.staging (git-ignored). Credentials mirror the postgres + # service below — no new secret introduced. + environment: + DATABASE_URL: postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking + REDIS_URL: redis://redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 + restart: unless-stopped + + postgres: + image: postgres:16-alpine + container_name: growqr-matchmaking-postgres + environment: + POSTGRES_USER: matchmaking + POSTGRES_PASSWORD: matchmaking + POSTGRES_DB: matchmaking + ports: + - "127.0.0.1:15446:5432" + volumes: + - matchmaking_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U matchmaking -d matchmaking"] + interval: 5s + timeout: 3s + retries: 20 + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: growqr-matchmaking-redis + restart: unless-stopped + +# External bridge — pre-created and shared so a cold redeploy works even if the +# network was last created under a different project name. +networks: + default: + name: matchmaking-v2_default + external: true + +# Project-scoped volume — the live `matchmaking-v2_matchmaking_pgdata` (47.7M PG +# cluster) is reused as-is. No rename, no migration, no data risk. +volumes: + matchmaking_pgdata: diff --git a/tests/test_staging_infra.py b/tests/test_staging_infra.py new file mode 100644 index 0000000..eeb7692 --- /dev/null +++ b/tests/test_staging_infra.py @@ -0,0 +1,224 @@ +"""Staging infra regression — the durable fixes this slice landed: + +1. Single-project topology: docker-compose.staging.yml deploys all three services + under ONE project name (`matchmaking-v2` — matches scripts/sync-staging.sh + compose_project and the live volume/network names) with fixed container names and + an external shared bridge (matchmaking-v2_default). The prior split (api under + `-p matchmaking-v2-staging`, postgres/redis under `-p matchmaking-v2`) created two + disjoint `_default` bridges → every dependency hostname was NXDOMAIN from + the api → feed store init failed with [Errno -2]. + +2. Project-scoped volume: the Postgres data volume stays project-scoped + (matchmaking-v2_matchmaking_pgdata) — the existing live volume with its PG cluster + is reused as-is, no migration or rename. + +3. Apify token redaction: the client authenticates via an Authorization header (not a + query param), so no request URL — and therefore no httpx.HTTPStatusError string — + can ever contain the token. A 403 surfaces as a token-free RuntimeError. + +These tests don't need a live stack — they lock the CONFIG shape and the ERROR SHAPE so +the regressions they guard (network split, token-in-logs) can't return. +""" +from __future__ import annotations + +import asyncio +from pathlib import Path + +import httpx +import pytest +import yaml + +from app.engine import apify_client + + +_COMPOSE = Path(__file__).resolve().parents[1] / "docker-compose.staging.yml" + + +def _load_compose() -> dict: + return yaml.safe_load(_COMPOSE.read_text()) + + +# ── 1. Single-project topology + external shared network ───────────────────── + +def test_staging_compose_uses_external_shared_network(): + """The default network must be external + named matchmaking-v2_default so a cold + redeploy works even if the bridge was last created under a different project.""" + d = _load_compose() + net = d["networks"]["default"] + assert net.get("external") is True, "network must be external (no project owns/removes it)" + assert net.get("name") == "matchmaking-v2_default", ( + "name must be the literal matchmaking-v2_default — the bridge postgres/redis already use" + ) + + +def test_staging_api_depends_on_postgres_and_redis(): + """The api service must depend on BOTH postgres and redis — this is the depends_on + graph that guarantees they land on the same Compose network when deployed as one + project. Breaking this (e.g. --no-deps deploy) reintroduces the network split.""" + d = _load_compose() + deps = d["services"]["api"].get("depends_on", {}) + assert "postgres" in deps, "api must depend_on postgres (same-network guarantee)" + assert "redis" in deps, "api must depend_on redis (same-network guarantee)" + assert deps["postgres"].get("condition") == "service_healthy", "postgres dep must wait for healthcheck" + assert deps["redis"].get("condition") == "service_started", "redis dep must wait for start" + + +def test_staging_api_pins_db_and_redis_hosts(): + """Inline environment overrides guarantee the api reaches postgres/redis by service + name even if the git-ignored .env.staging drifts to localhost.""" + d = _load_compose() + env = d["services"]["api"]["environment"] + assert env["DATABASE_URL"].endswith("@postgres:5432/matchmaking"), "DB host must be the compose service name" + assert env["REDIS_URL"].startswith("redis://redis:"), "Redis host must be the compose service name" + + +def test_staging_service_names_are_unique(): + """Unique container names prevent cross-project DNS ambiguity on the shared bridge.""" + d = _load_compose() + names = {s["container_name"] for s in d["services"].values()} + assert names == {"growqr-matchmaking-api", "growqr-matchmaking-postgres", "growqr-matchmaking-redis"} + + +# ── 2. Project-scoped volume ───────────────────────────────────────────────── + +def test_staging_postgres_mounts_volume(): + """The postgres service must mount the data volume at the PG data dir.""" + d = _load_compose() + mounts = d["services"]["postgres"]["volumes"] + assert any("matchmaking_pgdata:/var/lib/postgresql/data" in m for m in mounts), ( + "postgres must mount matchmaking_pgdata at the PG data directory" + ) + + +def test_staging_volume_is_project_scoped(): + """The volume must be project-scoped (NOT external) so the live + matchmaking-v2_matchmaking_pgdata is reused as-is — no rename, no data risk.""" + d = _load_compose() + vol = d["volumes"]["matchmaking_pgdata"] + assert vol is None or (isinstance(vol, dict) and not vol.get("external")), ( + "volume must NOT be external — the live project-scoped volume is reused as-is" + ) + + +# ── 2b. Deploy script alignment ───────────────────────────────────────────── + +_SYNC_STAGING = Path(__file__).resolve().parents[2] / "scripts" / "sync-staging.sh" + + +def test_sync_staging_compose_services_includes_all_three(): + """The deploy script must bring up all three services (api, postgres, redis) under + the single project — not just api. Without this, postgres/redis stay on whatever + project they were last started under, and the network split can recur.""" + if not _SYNC_STAGING.exists(): + pytest.skip("sync-staging.sh not in this checkout (run from growqr monorepo)") + text = _SYNC_STAGING.read_text() + # compose_services must have an explicit matchmaking-v2 case listing all three + assert "matchmaking-v2) printf 'api postgres redis'" in text, ( + "sync-staging.sh compose_services must list 'api postgres redis' for matchmaking-v2" + ) + + +def test_sync_staging_compose_networks_includes_shared_bridge(): + """The deploy guard must pre-create the external matchmaking-v2_default network + so `external: true` in the compose never fails on ordering.""" + if not _SYNC_STAGING.exists(): + pytest.skip("sync-staging.sh not in this checkout (run from growqr monorepo)") + text = _SYNC_STAGING.read_text() + assert "matchmaking-v2_default" in text, ( + "sync-staging.sh compose_networks must pre-create matchmaking-v2_default" + ) + + +def test_sync_staging_public_health_url_has_matchmaking_case(): + """public_health_url must have a matchmaking-v2 case — without it, the function + returns 1 (the `*)` fallthrough), and under `set -e` the command substitution at + the call site exits the script before the deploy runs.""" + if not _SYNC_STAGING.exists(): + pytest.skip("sync-staging.sh not in this checkout (run from growqr monorepo)") + text = _SYNC_STAGING.read_text() + assert "matchmaking-v2) printf 'https://matchmaking-staging.gqr.puter.wtf/api/v1/health'" in text, ( + "sync-staging.sh public_health_url must have a matchmaking-v2 case to avoid set -e abort" + ) + + +# ── 3. Apify token redaction ───────────────────────────────────────────────── + +_TOKEN = "apify_api_secret_TOKEN_value_DO_NOT_LEAK" + + +def test_auth_header_not_query_param(): + """Token must travel in a header, never in the URL.""" + h = apify_client._auth_headers(_TOKEN) + assert h == {"Authorization": f"Bearer {_TOKEN}"} + + +def test_run_actor_4xx_raises_token_free_error(monkeypatch): + """A provider 403 (the live failure mode) must surface WITHOUT the token in the message. + + The live Apify account returns 403 platform-feature-disabled (monthly cap exceeded). + That error must be explicit and safe — no credential in the exception or any log line. + """ + calls = {"post": 0} + + class _FakeResponse: + status_code = 403 + request = httpx.Request("POST", "https://api.apify.com/v2/acts/x/run-sync-get-dataset-items") + + def raise_for_status(self): + raise httpx.HTTPStatusError("403 Forbidden", request=self.request, response=self) + + def json(self): + return {} + + async def _fake_post(self, url, **kwargs): + calls["post"] += 1 + # CRITICAL: assert no token leaked into the URL or query params + assert "token" not in str(url), "token must not be in the request URL" + assert "token" not in str(kwargs.get("params", {})), "token must not be in query params" + return _FakeResponse() + + _FakeSettings = type("S", (), { + "APIFY_TOKEN": _TOKEN, "APIFY_CACHE_MODE": "off", "APIFY_CACHE_DIR": ".apify_cache", + }) + monkeypatch.setattr(apify_client, "get_settings", lambda: _FakeSettings()) + monkeypatch.setattr(httpx.AsyncClient, "post", _fake_post) + + with pytest.raises(RuntimeError) as exc_info: + asyncio.run(apify_client.run_actor("test~actor", {"q": "dev"})) + + assert calls["post"] >= 1, "the fake POST must be reached (else the test passed for the wrong reason)" + msg = str(exc_info.value) + assert _TOKEN not in msg, f"token leaked into error message: {msg!r}" + assert "403" in msg, f"the HTTP status must be in the message: {msg!r}" + + +def test_run_actor_async_4xx_raises_token_free_error(monkeypatch): + """The async-run path (incremental actors) must likewise not leak the token.""" + calls = {"post": 0} + + class _FakeResponse: + status_code = 403 + request = httpx.Request("POST", "https://api.apify.com/v2/acts/x/runs") + + def raise_for_status(self): + raise httpx.HTTPStatusError("403 Forbidden", request=self.request, response=self) + + def json(self): + return {} + + async def _fake_post(self, url, **kwargs): + calls["post"] += 1 + assert "token" not in str(url), "token must not be in the request URL" + return _FakeResponse() + + _FakeSettings = type("S", (), { + "APIFY_TOKEN": _TOKEN, "APIFY_CACHE_MODE": "off", "APIFY_CACHE_DIR": ".apify_cache", + }) + monkeypatch.setattr(apify_client, "get_settings", lambda: _FakeSettings()) + monkeypatch.setattr(httpx.AsyncClient, "post", _fake_post) + + with pytest.raises(RuntimeError) as exc_info: + asyncio.run(apify_client.run_actor("test~actor", {"q": "dev", "incremental": True})) + + assert calls["post"] >= 1, "the fake POST must be reached (else the test passed for the wrong reason)" + assert _TOKEN not in str(exc_info.value), "token leaked into async error message"