Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run one consistent backup: PostgreSQL, blobs/parsed artifacts, Qdrant snapshots.
|
||||
|
||||
Implements ``runbooks/BACKUP.md``'s steps 1-8 (step 9, the scheduled
|
||||
empty-environment restore test, is a separate task). See
|
||||
``src/ragcore/application/backup/service.py`` for the actual pipeline;
|
||||
this script only wires real settings and infrastructure to it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from ragcore.application.backup import BackupService
|
||||
from ragcore.config import get_settings
|
||||
from ragcore.infrastructure.backup import (
|
||||
LocalFileCopier,
|
||||
PgDumpPostgresDumper,
|
||||
QdrantHttpSnapshotter,
|
||||
)
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.models.generation.repository import (
|
||||
PostgresGenerationProfileStore,
|
||||
)
|
||||
from ragcore.infrastructure.models.persistence import JsonEmbeddingProfileStore
|
||||
|
||||
|
||||
async def active_profile_provenance(
|
||||
*, settings: object, session_factory: object
|
||||
) -> dict[str, str]:
|
||||
"""Non-secret ``profile:version`` identifiers of every currently active model profile.
|
||||
|
||||
An absent optional profile is simply omitted rather than failing the
|
||||
backup: a deployment without an active generation profile still deserves
|
||||
a complete backup, it just has less provenance to record.
|
||||
"""
|
||||
|
||||
provenance: dict[str, str] = {}
|
||||
embedding = await JsonEmbeddingProfileStore(
|
||||
settings.embedding_profiles_path # type: ignore[attr-defined]
|
||||
).get_active()
|
||||
if embedding is not None:
|
||||
provenance["embedding"] = f"{embedding.profile}:{embedding.version}"
|
||||
generation = await PostgresGenerationProfileStore(
|
||||
session_factory # type: ignore[arg-type]
|
||||
).get_active()
|
||||
if generation is not None:
|
||||
provenance["generation"] = f"{generation.profile}:{generation.version}"
|
||||
return provenance
|
||||
|
||||
|
||||
async def run(*, backup_root: Path, keep_complete: int) -> None:
|
||||
settings = get_settings()
|
||||
engine = create_database_engine(settings)
|
||||
session_factory = create_session_factory(engine)
|
||||
# Snapshot creation/download can legitimately take minutes for large
|
||||
# collections or slow disks; a short default timeout would abort a
|
||||
# otherwise-healthy backup.
|
||||
qdrant_client = AsyncQdrantClient(
|
||||
url=settings.qdrant_url, api_key=settings.qdrant_api_key.get_secret_value(), timeout=300
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=False, timeout=300) as http_client:
|
||||
collections = await qdrant_client.get_collections()
|
||||
collection_names = [collection.name for collection in collections.collections]
|
||||
|
||||
service = BackupService(
|
||||
postgres=PgDumpPostgresDumper(
|
||||
database_url=settings.database_url, session_factory=session_factory
|
||||
),
|
||||
qdrant=QdrantHttpSnapshotter(
|
||||
client=qdrant_client,
|
||||
http_client=http_client,
|
||||
base_url=settings.qdrant_url,
|
||||
api_key=settings.qdrant_api_key.get_secret_value(),
|
||||
),
|
||||
files=LocalFileCopier(),
|
||||
)
|
||||
manifest = await service.run(
|
||||
backup_root=backup_root,
|
||||
blob_root=settings.blob_root,
|
||||
parsed_root=settings.parsed_root,
|
||||
collection_names=collection_names,
|
||||
active_profiles=await active_profile_provenance(
|
||||
settings=settings, session_factory=session_factory
|
||||
),
|
||||
)
|
||||
removed = service.prune(backup_root=backup_root, keep_complete=keep_complete)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
await qdrant_client.close()
|
||||
|
||||
print(f"backup {manifest.backup_id} complete at {backup_root / manifest.backup_id}")
|
||||
if removed:
|
||||
print(f"pruned {len(removed)} old backup(s): {', '.join(removed)}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--backup-root",
|
||||
type=Path,
|
||||
default=Path(".state/backups"),
|
||||
help="Directory to store backups in",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-complete", type=int, default=7, help="Number of complete backups to retain"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(backup_root=args.backup_root, keep_complete=args.keep_complete))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Wait for app liveness and readiness from a CI or Compose network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
import urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def wait_for_health(base_url: str, timeout: float) -> None:
|
||||
parsed = urlsplit(base_url)
|
||||
if parsed.scheme not in {"http", "https"} or parsed.hostname is None:
|
||||
raise ValueError("base URL must be an absolute HTTP(S) URL")
|
||||
|
||||
urls = (f"{base_url.rstrip('/')}/health/live", f"{base_url.rstrip('/')}/health/ready")
|
||||
deadline = time.monotonic() + timeout
|
||||
last_error = "unknown health failure"
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
for url in urls:
|
||||
with urllib.request.urlopen(url, timeout=1) as response: # noqa: S310
|
||||
if response.status != 200:
|
||||
raise OSError(f"{url} returned HTTP {response.status}")
|
||||
return
|
||||
except OSError as exc:
|
||||
last_error = str(exc)
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(f"Timed out waiting for app health: {last_error}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8080")
|
||||
parser.add_argument("--timeout", type=float, default=30)
|
||||
args = parser.parse_args()
|
||||
wait_for_health(args.base_url, args.timeout)
|
||||
print("App liveness and readiness ready")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real-HTTP smoke test against a running RAGCore app process.
|
||||
|
||||
Runs *inside* the deployment network (e.g. the ``ci`` image on the Compose
|
||||
``backend`` network) and talks plain HTTP to the live app (uvicorn), proving
|
||||
the process boundary end to end:
|
||||
|
||||
* liveness/readiness endpoints and the OpenAPI version served;
|
||||
* authentication gating (401 on API routes without a token, login page on
|
||||
the Control Plane without a session);
|
||||
* a real bearer credential (provisioned through the real control-plane
|
||||
repositories) can call ``/v1/search`` and receives a well-formed response;
|
||||
* a real signed admin session (minted with the app's own signing key, the
|
||||
same mechanism the OIDC callback uses) renders every Control Plane screen:
|
||||
dashboard, sources, documents, jobs, system, query lab, applications,
|
||||
spaces, policies, profiles -- each 200 and carrying its expected markers.
|
||||
|
||||
Usage: uv run python scripts/http_smoke.py --base-url http://app:8080
|
||||
Exit code 0 when every check passed; non-zero otherwise. Nothing here is
|
||||
mocked; every failure prints the failing check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
from ragcore.application.auth import SignedTokenCodec, encode_session
|
||||
from ragcore.application.identity.credentials import ServiceAccountCredentialService
|
||||
from ragcore.config import get_settings
|
||||
from ragcore.domain.auth import Session
|
||||
from ragcore.domain.control import Role, Scope, ServiceAccount, Workspace
|
||||
from ragcore.infrastructure.database import (
|
||||
create_database_engine,
|
||||
create_session_factory,
|
||||
session_scope,
|
||||
)
|
||||
from ragcore.infrastructure.database.control import ControlPlaneRepository
|
||||
from ragcore.infrastructure.database.identity import PostgresCredentialRepository
|
||||
|
||||
CHECKS: list[tuple[str, bool, str]] = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, detail: str = "") -> None:
|
||||
CHECKS.append((name, ok, detail))
|
||||
print(f"[{'OK' if ok else 'FAIL'}] {name}{(' -- ' + detail) if detail else ''}")
|
||||
|
||||
|
||||
async def provision(settings) -> tuple[UUID, UUID, str, Workspace]: # type: ignore[no-untyped-def]
|
||||
engine = create_database_engine(settings)
|
||||
factory = create_session_factory(engine)
|
||||
workspace_id = uuid4()
|
||||
workspace = Workspace(
|
||||
workspace_id, f"Smoke {workspace_id.hex[:6]}", f"smoke-{workspace_id.hex[:8]}"
|
||||
)
|
||||
application = workspace.create_application(
|
||||
id=uuid4(),
|
||||
name="Smoke client",
|
||||
slug=f"smoke-client-{uuid4().hex[:8]}",
|
||||
scopes=frozenset({Scope.SEARCH, Scope.CONTEXT, Scope.SOURCES_SYNC}),
|
||||
)
|
||||
space = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Smoke space", slug=f"smoke-space-{uuid4().hex[:8]}"
|
||||
)
|
||||
account = ServiceAccount(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
application_id=application.id,
|
||||
name="Smoke service account",
|
||||
scopes=application.scopes,
|
||||
)
|
||||
async with session_scope(factory) as session:
|
||||
repository = ControlPlaneRepository(session)
|
||||
await repository.create_workspace(workspace)
|
||||
await repository.create_space(space)
|
||||
await repository.create_application(application)
|
||||
await repository.create_service_account(account)
|
||||
await repository.create_grant(
|
||||
workspace.grant(
|
||||
id=uuid4(), application=application, knowledge_space=space, role=Role.EDITOR
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def load(account_id: UUID) -> ServiceAccount | None:
|
||||
async with session_scope(factory) as session:
|
||||
return await ControlPlaneRepository(session).get_service_account_by_id(account_id)
|
||||
|
||||
async with session_scope(factory) as session:
|
||||
service = ServiceAccountCredentialService(
|
||||
PostgresCredentialRepository(session),
|
||||
settings.api_token_pepper.get_secret_value(),
|
||||
service_account_loader=load,
|
||||
)
|
||||
issued = await service.issue(account, scopes=account.scopes)
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
return workspace_id, space.id, issued.token, workspace
|
||||
|
||||
|
||||
def mint_session_cookie(settings, workspace_id: UUID) -> str: # type: ignore[no-untyped-def]
|
||||
"""The same signed-cookie session the OIDC callback issues (app signing key)."""
|
||||
|
||||
codec = SignedTokenCodec(
|
||||
secret=hashlib.sha256(settings.secret_key.get_secret_value().encode()).digest()
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
session = Session(
|
||||
id=uuid4(),
|
||||
subject=f"smoke-admin-{uuid4().hex[:8]}",
|
||||
workspace_id=workspace_id,
|
||||
roles=frozenset({Role.PLATFORM_ADMIN}),
|
||||
issued_at=now,
|
||||
expires_at=now + timedelta(minutes=30),
|
||||
csrf_token=uuid4().hex,
|
||||
)
|
||||
return encode_session(session, codec=codec)
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", required=True)
|
||||
args = parser.parse_args()
|
||||
settings = get_settings()
|
||||
base = args.base_url.rstrip("/")
|
||||
|
||||
async with httpx.AsyncClient(base_url=base, timeout=30.0, follow_redirects=False) as http:
|
||||
live = await http.get("/health/live")
|
||||
check("GET /health/live is 200", live.status_code == 200, str(live.status_code))
|
||||
ready = await http.get("/health/ready")
|
||||
check("GET /health/ready is 200", ready.status_code == 200, ready.text[:200])
|
||||
openapi = await http.get("/openapi.json")
|
||||
version = (
|
||||
openapi.json().get("info", {}).get("version") if openapi.status_code == 200 else None
|
||||
)
|
||||
check("OpenAPI served with the release version", version == "1.2.0", str(version))
|
||||
unauth = await http.post("/v1/search", json={"query": "x"})
|
||||
check(
|
||||
"POST /v1/search without a token is 401",
|
||||
unauth.status_code == 401,
|
||||
str(unauth.status_code),
|
||||
)
|
||||
anon_admin = await http.get("/admin/sources")
|
||||
check(
|
||||
"GET /admin/sources without a session is not served (401/redirect)",
|
||||
anon_admin.status_code in {401, 302, 303, 307},
|
||||
str(anon_admin.status_code),
|
||||
)
|
||||
login = await http.get("/login")
|
||||
check(
|
||||
"GET /login renders",
|
||||
login.status_code == 200 and "RAGCore" in login.text,
|
||||
str(login.status_code),
|
||||
)
|
||||
|
||||
workspace_id, space_id, token, _ = await provision(settings)
|
||||
search = await http.post(
|
||||
"/v1/search",
|
||||
json={"query": "smoke", "requested_space_ids": [str(space_id)]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
body = (
|
||||
search.json()
|
||||
if search.headers.get("content-type", "").startswith("application/json")
|
||||
else {}
|
||||
)
|
||||
check(
|
||||
"POST /v1/search with a real bearer credential is 200 with the effective space",
|
||||
search.status_code == 200 and body.get("effective_space_ids") == [str(space_id)],
|
||||
f"{search.status_code} {str(body)[:160]}",
|
||||
)
|
||||
|
||||
cookie = mint_session_cookie(settings, workspace_id)
|
||||
http.cookies.set("ragcore_session", cookie)
|
||||
screens = {
|
||||
"/admin/dashboard": "Dashboard",
|
||||
"/admin/control/spaces": "Knowledge spaces",
|
||||
"/admin/sources": "Sources",
|
||||
"/admin/sources/new": "Add source",
|
||||
"/admin/documents": "Documents",
|
||||
"/admin/jobs": "Ingestion Jobs",
|
||||
"/admin/system": "System Health",
|
||||
"/admin/query-lab": "Query Lab",
|
||||
"/admin/control/applications": "Applications",
|
||||
"/admin/control/policies": "Policies",
|
||||
"/admin/profiles": "Profile",
|
||||
}
|
||||
for path, marker in screens.items():
|
||||
response = await http.get(path)
|
||||
check(
|
||||
f"GET {path} renders with a real session",
|
||||
response.status_code == 200 and marker in response.text,
|
||||
f"{response.status_code}",
|
||||
)
|
||||
nav = await http.get("/admin/dashboard")
|
||||
check(
|
||||
"grouped navigation present",
|
||||
all(label in nav.text for label in ("Knowledge", "Access", "Quality", "Operations")),
|
||||
)
|
||||
created = await http.post(
|
||||
"/admin/sources",
|
||||
data={
|
||||
"name": "Smoke runbooks",
|
||||
"kind": "folder",
|
||||
"space_id": str(space_id),
|
||||
"root": "/mnt/smoke-runbooks",
|
||||
"trust_level": "trusted_internal",
|
||||
},
|
||||
)
|
||||
check(
|
||||
"POST /admin/sources creates a folder source (303)",
|
||||
created.status_code == 303,
|
||||
str(created.status_code),
|
||||
)
|
||||
if created.status_code == 303:
|
||||
detail = await http.get(created.headers["location"])
|
||||
check(
|
||||
"source detail renders with configuration and sync action",
|
||||
detail.status_code == 200
|
||||
and "/mnt/smoke-runbooks" in detail.text
|
||||
and "Sync now" in detail.text,
|
||||
str(detail.status_code),
|
||||
)
|
||||
listing = await http.get("/admin/sources?q=smoke")
|
||||
check("sources list search finds it", "Smoke runbooks" in listing.text)
|
||||
source_path = created.headers["location"].split("?")[0]
|
||||
synced = await http.post(f"{source_path}/sync")
|
||||
check(
|
||||
"Sync now enqueues a sync_source job (303)",
|
||||
synced.status_code == 303,
|
||||
str(synced.status_code),
|
||||
)
|
||||
jobs = await http.get("/admin/jobs?operation=sync_source")
|
||||
check("jobs screen lists the sync job", "Sync source" in jobs.text)
|
||||
lab = await http.post(
|
||||
"/admin/query-lab/run",
|
||||
data={"query": "smoke test query", "space_ids": [str(space_id)]},
|
||||
)
|
||||
check(
|
||||
"Query Lab run renders the inspector",
|
||||
lab.status_code == 200 and "Retrieval inspector" in lab.text,
|
||||
str(lab.status_code),
|
||||
)
|
||||
|
||||
failed = [name for name, ok, _ in CHECKS if not ok]
|
||||
print(f"\n{len(CHECKS) - len(failed)}/{len(CHECKS)} checks passed")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run one empty-environment restore gate against a completed backup.
|
||||
|
||||
Implements ``runbooks/RESTORE.md``: restores a verified backup into a
|
||||
disposable database and disposable Qdrant collections, reconciles point
|
||||
counts, re-verifies known chunks/citations, and proves cross-space denial
|
||||
still holds -- all without ever writing to the live source. See
|
||||
``src/ragcore/application/restore/service.py`` for the actual pipeline;
|
||||
this script only wires real settings and infrastructure to it, and reports
|
||||
each stage's outcome.
|
||||
|
||||
Golden-query probes and the security-denial probe are supplied via a small
|
||||
JSON file (``--probes``) since they depend on what was actually indexed in
|
||||
the backed-up environment -- this script has no way to infer them on its
|
||||
own. See ``runbooks/RESTORE.md`` for the expected shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from ragcore.application.restore import GoldenQueryProbe, RestoreGateService, SecurityDenialProbe
|
||||
from ragcore.config import get_settings
|
||||
from ragcore.infrastructure.backup import LocalFileCopier
|
||||
from ragcore.infrastructure.restore import (
|
||||
QdrantHttpRestorer,
|
||||
SqlAlchemyControlPlaneReader,
|
||||
SqlAlchemyPostgresRestorer,
|
||||
)
|
||||
|
||||
|
||||
def _load_probes(path: Path) -> tuple[tuple[GoldenQueryProbe, ...], SecurityDenialProbe]:
|
||||
body = json.loads(path.read_text())
|
||||
golden_queries = tuple(
|
||||
GoldenQueryProbe(
|
||||
collection_alias=item["collection_alias"],
|
||||
point_id=UUID(item["point_id"]),
|
||||
query_vector=tuple(item["query_vector"]),
|
||||
expected_excerpt_checksum=item["expected_excerpt_checksum"],
|
||||
)
|
||||
for item in body.get("golden_queries", ())
|
||||
)
|
||||
security = body["security_denial"]
|
||||
security_probe = SecurityDenialProbe(
|
||||
principal_id=UUID(security["principal_id"]),
|
||||
workspace_id=UUID(security["workspace_id"]),
|
||||
granted_space_id=UUID(security["granted_space_id"]),
|
||||
denied_space_id=UUID(security["denied_space_id"]),
|
||||
)
|
||||
return golden_queries, security_probe
|
||||
|
||||
|
||||
async def run(*, backup_dir: Path, probes_path: Path, disposable_suffix: str) -> None:
|
||||
settings = get_settings()
|
||||
golden_queries, security_probe = _load_probes(probes_path)
|
||||
|
||||
# Snapshot recovery can legitimately take minutes for large collections
|
||||
# or slow disks; a short default timeout would abort an otherwise-healthy
|
||||
# restore.
|
||||
qdrant_client = AsyncQdrantClient(
|
||||
url=settings.qdrant_url, api_key=settings.qdrant_api_key.get_secret_value(), timeout=300
|
||||
)
|
||||
postgres_restorer = SqlAlchemyPostgresRestorer(admin_database_url=settings.database_url)
|
||||
disposable_database_name = f"restore_gate_{disposable_suffix}"
|
||||
disposable_collection_prefix = f"restored-{disposable_suffix}"
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="ragcore-restore-gate-") as disposable_root:
|
||||
service = RestoreGateService(
|
||||
postgres=postgres_restorer,
|
||||
qdrant=QdrantHttpRestorer(client=qdrant_client),
|
||||
control_plane=SqlAlchemyControlPlaneReader(),
|
||||
files=LocalFileCopier(),
|
||||
)
|
||||
report = await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=settings.database_url,
|
||||
disposable_database_name=disposable_database_name,
|
||||
disposable_collection_prefix=disposable_collection_prefix,
|
||||
disposable_blob_root=Path(disposable_root) / "blobs",
|
||||
disposable_parsed_root=Path(disposable_root) / "parsed",
|
||||
golden_queries=golden_queries,
|
||||
security_probe=security_probe,
|
||||
)
|
||||
finally:
|
||||
await postgres_restorer.drop_database(database_name=disposable_database_name)
|
||||
await qdrant_client.close()
|
||||
|
||||
print(f"restore gate for backup {report.backup_id}: {'PASS' if report.passed else 'FAIL'}")
|
||||
print(f" schema revision: {report.schema_revision}")
|
||||
print(f" postgres row counts: {report.postgres_row_counts}")
|
||||
for result in report.reconciliation:
|
||||
status = "match" if result.matches else "MISMATCH"
|
||||
print(
|
||||
f" reconciliation[{result.collection_alias}]: {status} "
|
||||
f"(missing={len(result.missing_point_ids)}, orphan={len(result.orphan_point_ids)})"
|
||||
)
|
||||
for golden in report.golden_queries:
|
||||
print(f" golden query[{golden.collection_alias}]: {'pass' if golden.passed else 'FAIL'}")
|
||||
if report.security_denial is not None:
|
||||
print(f" security denial: {'pass' if report.security_denial.passed else 'FAIL'}")
|
||||
|
||||
if not report.passed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--backup-dir", type=Path, required=True, help="Path to one backup set")
|
||||
parser.add_argument(
|
||||
"--probes",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="JSON file with golden_queries and security_denial probes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disposable-suffix",
|
||||
default="cli",
|
||||
help="Suffix for the disposable database/collection names this run creates",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(
|
||||
run(
|
||||
backup_dir=args.backup_dir,
|
||||
probes_path=args.probes,
|
||||
disposable_suffix=args.disposable_suffix,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Wait for PostgreSQL TCP and Qdrant HTTP during local development."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
def wait_tcp(host: str, port: int, deadline: float) -> None:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=1):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(f"Timed out waiting for {host}:{port}")
|
||||
|
||||
|
||||
def wait_http(url: str, deadline: float) -> None:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=1) as response: # noqa: S310 - caller supplies fixed local HTTP URL
|
||||
if response.status < 500:
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(f"Timed out waiting for {url}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--timeout", type=float, default=60)
|
||||
args = parser.parse_args()
|
||||
deadline = time.monotonic() + args.timeout
|
||||
wait_tcp("127.0.0.1", 5432, deadline)
|
||||
wait_http("http://127.0.0.1:6333/healthz", deadline)
|
||||
print("Services ready")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user