Files
svcforge/tests/integration/conftest.py
T
Nguyen Minh Phuc c76154aeaa
ci / lint (push) Successful in 34s
ci / unit (push) Successful in 1m41s
ci / types (push) Successful in 1m41s
ci / dockerfile (push) Successful in 18s
ci / security (push) Successful in 1m27s
ci / chart (push) Failing after 1m11s
ci / integration (push) Successful in 1m10s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
review: fix 26 findings from a 4-agent audit
CORRECTNESS
- lost-lease race: complete()/fail() did not check ownership, so a worker whose
  lease expired could mark a task done while another worker was running it, or
  requeue a task someone else owned. Reproduced, fixed with a CAS on
  (state, locked_by), pinned by two regression tests.
- worker died on report failure: _run_one's docstring claimed no exception
  escapes the TaskGroup; fail()/complete() were outside the guarded block, so a
  DB blip cancelled every sibling provision on the pod.
- claim query used an INNER join, which could strand a just-claimed task and
  report 'queue empty'. LEFT join.
- InstanceRepo.set_error bypassed the state machine and had no callers. Deleted.
- handle_deprovision ignored its CAS result, so a wrong-state instance kept a
  dangling endpoint and got re-provisioned by the drift check 60s later.
- handle_verify re-notified on every retry: five pages for one halt.

DEPLOY-BREAKING
- the migration Job could never succeed: no Dockerfile copied migrations/, and
  migrate.py resolved the path relative to the source tree, which only works for
  an editable install. Added COPY + SVCFORGE_MIGRATIONS_DIR.
- ServiceMonitor selector did not match the Service: API metrics never scraped.
- SvcforgeReconcilerStale fired permanently from every pod, because the gauge is
  module-level and every service exports it as 0. Scoped to the reconciler job.
- SvcforgeTaskFailed latched forever on a monotonic counter. Now increase()[15m].
- the digest guard accepted the all-zeros placeholder.
- worker terminationGracePeriodSeconds was 60s against a 600s helm timeout.

DEAD CODE THAT SHOULD NOT HAVE BEEN
- adapters/k8s.py was never called, so tenant namespaces were never created and
  the first provision for a new team would fail. Wired into handle_provision.
- adapters/redis.py was never imported by any service. Rate limiting is now wired
  into the API, failing open.
- Settings.check_production() had no callers. Given an explicit environment and
  called from every entrypoint.

OBSERVABILITY
- the API never called obs.setup(): no JSON logs, no trace correlation, log_json
  silently inert.
- LogNotifier's structured fields were discarded by the stdlib->structlog bridge.
- bind_task_context cleared the 'service' binding for the life of every task.
- split tasks_failed into task_attempts_failed and tasks_dead_lettered.

SECURITY
- trivy correctly blocked the worker/reconciler images: helm 3.16.2 and kubectl
  1.31.2 carry CRITICAL Go stdlib CVEs. Bumped to helm 3.21.3 and kubectl 1.35.3,
  which also closes a four-minor skew against the v1.35.3 cluster.

TESTS THAT COULD NOT FAIL
- the concurrency cap test passed on a fully serial worker.
- the alert/metric cross-check asserted a hardcoded list instead of reading the
  chart, so it could not catch a rename on the chart side.
- fixed OTel tracer-provider pollution between test files.

DOCS
- ARCHITECTURE.md: mermaid diagrams, user stories, and the helm-vs-ArgoCD
  guarantee (verified with --dry-run=server).
- AGENTS.md + CLAUDE.md.
- prose sweep for back-and-forth phrasing across 19 files.
2026-07-18 12:13:49 +00:00

116 lines
4.5 KiB
Python

"""Integration fixtures: a real Postgres, real SQL, no mocks.
The DB is never mocked. A mocked database proves your mock returns what you told it to.
Every bug worth catching here — SKIP LOCKED semantics, CAS rowcounts, transaction
rollback, `timestamptz` round-tripping — lives in the part a mock replaces.
Two ways to get a database, in priority order:
1. `SVCFORGE_TEST_DSN` in the environment — an already-running Postgres. This is the
path on a host that has no Docker daemon (for example one whose containerd belongs
to a Kubernetes kubelet, where installing Docker would evict the runtime).
2. testcontainers, which starts `postgres:16-alpine` and throws it away after. This is
the CI path.
Same tests either way.
"""
from __future__ import annotations
import os
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import psycopg
import pytest
import pytest_asyncio
from svcforge_core.repo.db import DictPool, make_pool
MIGRATIONS = Path(__file__).resolve().parents[2] / "migrations"
def _apply_migrations(dsn: str) -> None:
"""Run every migration in lexical order, one transaction each.
No `create table if not exists` and no reset: every caller hands this a database that
was created moments ago (a per-process clone, or a fresh container), so the schema is
always empty and the migrations always apply cleanly from zero.
"""
with psycopg.connect(dsn, autocommit=True) as conn:
for path in sorted(MIGRATIONS.glob("*.sql")):
with conn.transaction(), conn.cursor() as cur:
cur.execute(path.read_text(encoding="utf-8"))
def _private_database(admin_dsn: str) -> Iterator[str]:
"""Clone a scratch database for THIS pytest process only, and drop it after.
The `pool` fixture truncates between tests. That is correct within one process and
catastrophic across several: two pytest runs sharing a database truncate each other's
rows mid-test, and the failures look like real bugs in the code under test rather than
like the harness eating itself. Isolating per process makes concurrent runs
(several agents, or pytest-xdist -n auto) simply work.
"""
name = f"svcforge_test_{os.getpid()}"
parsed = urlsplit(admin_dsn)
with psycopg.connect(admin_dsn, autocommit=True) as conn:
conn.execute(f'drop database if exists "{name}"')
conn.execute(f'create database "{name}"')
dsn = urlunsplit(parsed._replace(path=f"/{name}"))
try:
_apply_migrations(dsn)
yield dsn
finally:
with psycopg.connect(admin_dsn, autocommit=True) as conn:
# Boot any lingering connections, or the drop blocks forever.
conn.execute(
"select pg_terminate_backend(pid) from pg_stat_activity where datname = %s",
(name,),
)
conn.execute(f'drop database if exists "{name}"')
@pytest.fixture(scope="session")
def pg_dsn() -> Iterator[str]:
"""A migrated Postgres, private to this process, from the environment or a container."""
env_dsn = os.getenv("SVCFORGE_TEST_DSN")
if env_dsn:
yield from _private_database(env_dsn)
return
try:
from testcontainers.postgres import PostgresContainer
except ImportError: # pragma: no cover - CI always has it
pytest.skip("set SVCFORGE_TEST_DSN or install testcontainers")
# A container is already private to this process; no need to clone inside it.
with PostgresContainer("postgres:16-alpine", driver=None) as pg:
dsn = pg.get_connection_url()
_apply_migrations(dsn)
yield dsn
@pytest_asyncio.fixture
async def pool(pg_dsn: str) -> AsyncIterator[DictPool]:
"""A clean database and an open pool, per test.
max_size=60 is a correctness requirement. test_skip_locked_claims_each_task_exactly_once
races 50 concurrent claims; a pool smaller than that serialises them at the pool
instead of at the database, and the test passes while proving nothing.
"""
async with await psycopg.AsyncConnection.connect(pg_dsn, autocommit=True) as conn:
# catalog_versions joins the list because a halted rollout is sticky by design:
# leave it behind and every later test in the session sees an empty work list.
await conn.execute("truncate tasks, instances, catalog_versions restart identity cascade")
p = make_pool(pg_dsn, min_size=1, max_size=60)
await p.open(wait=True)
try:
yield p
finally:
await p.close()