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
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.
116 lines
4.5 KiB
Python
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()
|