svc-infra 0.1.600__py3-none-any.whl → 0.1.664__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/setup.py +0 -2
- svc_infra/api/fastapi/auth/add.py +0 -4
- svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
- svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +114 -0
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +3 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +21 -13
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +28 -8
- svc_infra/cli/cmds/__init__.py +8 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/sql/repository.py +51 -11
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/docs/acceptance-matrix.md +88 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
- svc_infra/docs/api.md +186 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/storage.md +982 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/webhook_delivery.py +14 -2
- svc_infra/jobs/queue.py +9 -1
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/worker.py +17 -1
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +6 -2
- svc_infra/security/models.py +27 -7
- svc_infra/security/oauth_models.py +59 -0
- svc_infra/security/permissions.py +1 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +214 -0
- svc_infra/storage/backends/s3.py +329 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +182 -0
- svc_infra/storage/settings.py +192 -0
- svc_infra/webhooks/service.py +10 -2
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
svc_infra/http/client.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from svc_infra.app.env import pick
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_float_env(name: str, default: float) -> float:
|
|
12
|
+
raw = os.getenv(name)
|
|
13
|
+
if raw is None or raw == "":
|
|
14
|
+
return default
|
|
15
|
+
try:
|
|
16
|
+
return float(raw)
|
|
17
|
+
except ValueError:
|
|
18
|
+
return default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_default_timeout_seconds() -> float:
|
|
22
|
+
"""Return default outbound HTTP client timeout in seconds.
|
|
23
|
+
|
|
24
|
+
Env var: HTTP_CLIENT_TIMEOUT_SECONDS (float)
|
|
25
|
+
Defaults: 10.0 seconds for all envs unless overridden; tweakable via pick() if needed.
|
|
26
|
+
"""
|
|
27
|
+
default = pick(prod=10.0, nonprod=10.0)
|
|
28
|
+
return _parse_float_env("HTTP_CLIENT_TIMEOUT_SECONDS", default)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_timeout(seconds: float | None = None) -> httpx.Timeout:
|
|
32
|
+
s = seconds if seconds is not None else get_default_timeout_seconds()
|
|
33
|
+
# Apply same timeout for connect/read/write/pool for simplicity
|
|
34
|
+
return httpx.Timeout(timeout=s)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def new_httpx_client(
|
|
38
|
+
*,
|
|
39
|
+
timeout_seconds: Optional[float] = None,
|
|
40
|
+
headers: Optional[Dict[str, str]] = None,
|
|
41
|
+
base_url: Optional[str] = None,
|
|
42
|
+
**kwargs: Any,
|
|
43
|
+
) -> httpx.Client:
|
|
44
|
+
"""Create a sync httpx Client with default timeout and optional headers/base_url.
|
|
45
|
+
|
|
46
|
+
Callers can override timeout_seconds; remaining kwargs are forwarded to httpx.Client.
|
|
47
|
+
"""
|
|
48
|
+
timeout = make_timeout(timeout_seconds)
|
|
49
|
+
# httpx doesn't accept base_url=None; only pass if non-None
|
|
50
|
+
client_kwargs = {"timeout": timeout, "headers": headers, **kwargs}
|
|
51
|
+
if base_url is not None:
|
|
52
|
+
client_kwargs["base_url"] = base_url
|
|
53
|
+
return httpx.Client(**client_kwargs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def new_async_httpx_client(
|
|
57
|
+
*,
|
|
58
|
+
timeout_seconds: Optional[float] = None,
|
|
59
|
+
headers: Optional[Dict[str, str]] = None,
|
|
60
|
+
base_url: Optional[str] = None,
|
|
61
|
+
**kwargs: Any,
|
|
62
|
+
) -> httpx.AsyncClient:
|
|
63
|
+
"""Create an async httpx AsyncClient with default timeout and optional headers/base_url.
|
|
64
|
+
|
|
65
|
+
Callers can override timeout_seconds; remaining kwargs are forwarded to httpx.AsyncClient.
|
|
66
|
+
"""
|
|
67
|
+
timeout = make_timeout(timeout_seconds)
|
|
68
|
+
# httpx doesn't accept base_url=None; only pass if non-None
|
|
69
|
+
client_kwargs = {"timeout": timeout, "headers": headers, **kwargs}
|
|
70
|
+
if base_url is not None:
|
|
71
|
+
client_kwargs["base_url"] = base_url
|
|
72
|
+
return httpx.AsyncClient(**client_kwargs)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import os
|
|
4
4
|
|
|
5
5
|
from svc_infra.db.inbox import InboxStore
|
|
6
6
|
from svc_infra.db.outbox import OutboxStore
|
|
7
|
+
from svc_infra.http import get_default_timeout_seconds, new_async_httpx_client
|
|
7
8
|
from svc_infra.jobs.queue import Job
|
|
8
9
|
from svc_infra.webhooks.signing import sign
|
|
9
10
|
|
|
@@ -65,7 +66,18 @@ def make_webhook_handler(
|
|
|
65
66
|
version = delivery_payload.get("version")
|
|
66
67
|
if version is not None:
|
|
67
68
|
headers["X-Payload-Version"] = str(version)
|
|
68
|
-
|
|
69
|
+
# Derive timeout: dedicated WEBHOOK_DELIVERY_TIMEOUT_SECONDS or default HTTP client timeout
|
|
70
|
+
timeout_seconds = None
|
|
71
|
+
env_timeout = os.getenv("WEBHOOK_DELIVERY_TIMEOUT_SECONDS")
|
|
72
|
+
if env_timeout:
|
|
73
|
+
try:
|
|
74
|
+
timeout_seconds = float(env_timeout)
|
|
75
|
+
except ValueError:
|
|
76
|
+
timeout_seconds = get_default_timeout_seconds()
|
|
77
|
+
else:
|
|
78
|
+
timeout_seconds = get_default_timeout_seconds()
|
|
79
|
+
|
|
80
|
+
async with new_async_httpx_client(timeout_seconds=timeout_seconds) as client:
|
|
69
81
|
resp = await client.post(url, json=delivery_payload, headers=headers)
|
|
70
82
|
if 200 <= resp.status_code < 300:
|
|
71
83
|
# record delivery and mark processed
|
svc_infra/jobs/queue.py
CHANGED
|
@@ -69,5 +69,13 @@ class InMemoryJobQueue:
|
|
|
69
69
|
job.last_error = error
|
|
70
70
|
# Exponential backoff: base * attempts
|
|
71
71
|
delay = job.backoff_seconds * max(1, job.attempts)
|
|
72
|
-
|
|
72
|
+
if delay > 0:
|
|
73
|
+
# Add a tiny fudge so an immediate subsequent poll in ultra-fast
|
|
74
|
+
# environments (like our acceptance API) doesn't re-reserve the job.
|
|
75
|
+
# This keeps tests deterministic without impacting semantics.
|
|
76
|
+
job.available_at = now + timedelta(seconds=delay, milliseconds=250)
|
|
77
|
+
else:
|
|
78
|
+
# When backoff is explicitly zero (e.g., unit tests forcing
|
|
79
|
+
# immediate retry), make the job available right away.
|
|
80
|
+
job.available_at = now
|
|
73
81
|
return
|
svc_infra/jobs/runner.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from typing import Awaitable, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from .queue import JobQueue
|
|
8
|
+
|
|
9
|
+
ProcessFunc = Callable[[object], Awaitable[None]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkerRunner:
|
|
13
|
+
"""Cooperative worker loop with graceful stop.
|
|
14
|
+
|
|
15
|
+
- start(): begin polling the queue and processing jobs
|
|
16
|
+
- stop(grace_seconds): signal stop, wait up to grace for current job to finish
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, queue: JobQueue, handler: ProcessFunc, *, poll_interval: float = 0.25):
|
|
20
|
+
self._queue = queue
|
|
21
|
+
self._handler = handler
|
|
22
|
+
self._poll_interval = poll_interval
|
|
23
|
+
self._task: Optional[asyncio.Task] = None
|
|
24
|
+
self._stopping = asyncio.Event()
|
|
25
|
+
self._inflight: Optional[asyncio.Task] = None
|
|
26
|
+
|
|
27
|
+
async def _loop(self) -> None:
|
|
28
|
+
try:
|
|
29
|
+
while not self._stopping.is_set():
|
|
30
|
+
job = self._queue.reserve_next()
|
|
31
|
+
if not job:
|
|
32
|
+
await asyncio.sleep(self._poll_interval)
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
# Process one job; track in-flight task for stop()
|
|
36
|
+
async def _run():
|
|
37
|
+
try:
|
|
38
|
+
await self._handler(job)
|
|
39
|
+
except Exception as exc: # pragma: no cover
|
|
40
|
+
self._queue.fail(job.id, error=str(exc))
|
|
41
|
+
return
|
|
42
|
+
self._queue.ack(job.id)
|
|
43
|
+
|
|
44
|
+
self._inflight = asyncio.create_task(_run())
|
|
45
|
+
try:
|
|
46
|
+
await self._inflight
|
|
47
|
+
finally:
|
|
48
|
+
self._inflight = None
|
|
49
|
+
finally:
|
|
50
|
+
# exiting loop
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def start(self) -> asyncio.Task:
|
|
54
|
+
if self._task is None or self._task.done():
|
|
55
|
+
self._task = asyncio.create_task(self._loop())
|
|
56
|
+
return self._task
|
|
57
|
+
|
|
58
|
+
async def stop(self, *, grace_seconds: float = 10.0) -> None:
|
|
59
|
+
self._stopping.set()
|
|
60
|
+
# Wait for in-flight job to complete, up to grace
|
|
61
|
+
if self._inflight is not None and not self._inflight.done():
|
|
62
|
+
try:
|
|
63
|
+
await asyncio.wait_for(self._inflight, timeout=grace_seconds)
|
|
64
|
+
except asyncio.TimeoutError:
|
|
65
|
+
# Give up; job will be retried if your queue supports visibility timeouts
|
|
66
|
+
pass
|
|
67
|
+
# Finally, wait for loop to exit (should be quick since stopping is set)
|
|
68
|
+
if self._task is not None:
|
|
69
|
+
try:
|
|
70
|
+
await asyncio.wait_for(self._task, timeout=max(0.1, self._poll_interval + 0.1))
|
|
71
|
+
except asyncio.TimeoutError:
|
|
72
|
+
# Cancel as a last resort
|
|
73
|
+
self._task.cancel()
|
|
74
|
+
with contextlib.suppress(Exception):
|
|
75
|
+
await self._task
|
svc_infra/jobs/worker.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
3
5
|
from typing import Awaitable, Callable
|
|
4
6
|
|
|
5
7
|
from .queue import Job, JobQueue
|
|
@@ -7,6 +9,16 @@ from .queue import Job, JobQueue
|
|
|
7
9
|
ProcessFunc = Callable[[Job], Awaitable[None]]
|
|
8
10
|
|
|
9
11
|
|
|
12
|
+
def _get_job_timeout_seconds() -> float | None:
|
|
13
|
+
raw = os.getenv("JOB_DEFAULT_TIMEOUT_SECONDS")
|
|
14
|
+
if not raw:
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
return float(raw)
|
|
18
|
+
except ValueError:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
10
22
|
async def process_one(queue: JobQueue, handler: ProcessFunc) -> bool:
|
|
11
23
|
"""Reserve a job, process with handler, ack on success or fail with backoff.
|
|
12
24
|
|
|
@@ -16,7 +28,11 @@ async def process_one(queue: JobQueue, handler: ProcessFunc) -> bool:
|
|
|
16
28
|
if not job:
|
|
17
29
|
return False
|
|
18
30
|
try:
|
|
19
|
-
|
|
31
|
+
timeout = _get_job_timeout_seconds()
|
|
32
|
+
if timeout and timeout > 0:
|
|
33
|
+
await asyncio.wait_for(handler(job), timeout=timeout)
|
|
34
|
+
else:
|
|
35
|
+
await handler(job)
|
|
20
36
|
except Exception as exc: # pragma: no cover - exercise in tests by raising
|
|
21
37
|
queue.fail(job.id, error=str(exc))
|
|
22
38
|
return True
|
svc_infra/mcp/svc_infra_mcp.py
CHANGED
|
@@ -5,6 +5,9 @@ from enum import Enum
|
|
|
5
5
|
from ai_infra.llm.tools.custom.cli import cli_cmd_help, cli_subcmd_help
|
|
6
6
|
from ai_infra.mcp.server.tools import mcp_from_functions
|
|
7
7
|
|
|
8
|
+
from svc_infra.app.env import prepare_env
|
|
9
|
+
from svc_infra.cli.foundation.runner import run_from_root
|
|
10
|
+
|
|
8
11
|
CLI_PROG = "svc-infra"
|
|
9
12
|
|
|
10
13
|
|
|
@@ -17,34 +20,73 @@ async def svc_infra_cmd_help() -> dict:
|
|
|
17
20
|
return await cli_cmd_help(CLI_PROG)
|
|
18
21
|
|
|
19
22
|
|
|
23
|
+
# No dedicated 'docs list' function — users can use 'docs --help' to discover topics.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def svc_infra_docs_help() -> dict:
|
|
27
|
+
"""
|
|
28
|
+
Run 'svc-infra docs --help' and return its output.
|
|
29
|
+
Prepares the project environment and executes from the repo root so
|
|
30
|
+
environment-provided docs directories and local topics are discoverable.
|
|
31
|
+
"""
|
|
32
|
+
root = prepare_env()
|
|
33
|
+
text = await run_from_root(root, CLI_PROG, ["docs", "--help"])
|
|
34
|
+
return {
|
|
35
|
+
"ok": True,
|
|
36
|
+
"action": "docs_help",
|
|
37
|
+
"project_root": str(root),
|
|
38
|
+
"help": text,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
20
42
|
class Subcommand(str, Enum):
|
|
21
|
-
# SQL commands
|
|
22
|
-
sql_init = "sql
|
|
23
|
-
sql_revision = "sql
|
|
24
|
-
sql_upgrade = "sql
|
|
25
|
-
sql_downgrade = "sql
|
|
26
|
-
sql_current = "sql
|
|
27
|
-
sql_history = "sql
|
|
28
|
-
sql_stamp = "sql
|
|
29
|
-
sql_merge_heads = "sql
|
|
30
|
-
sql_setup_and_migrate = "sql
|
|
31
|
-
sql_scaffold = "sql
|
|
32
|
-
sql_scaffold_models = "sql
|
|
33
|
-
sql_scaffold_schemas = "sql
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
# SQL group commands
|
|
44
|
+
sql_init = "sql init"
|
|
45
|
+
sql_revision = "sql revision"
|
|
46
|
+
sql_upgrade = "sql upgrade"
|
|
47
|
+
sql_downgrade = "sql downgrade"
|
|
48
|
+
sql_current = "sql current"
|
|
49
|
+
sql_history = "sql history"
|
|
50
|
+
sql_stamp = "sql stamp"
|
|
51
|
+
sql_merge_heads = "sql merge-heads"
|
|
52
|
+
sql_setup_and_migrate = "sql setup-and-migrate"
|
|
53
|
+
sql_scaffold = "sql scaffold"
|
|
54
|
+
sql_scaffold_models = "sql scaffold-models"
|
|
55
|
+
sql_scaffold_schemas = "sql scaffold-schemas"
|
|
56
|
+
sql_export_tenant = "sql export-tenant"
|
|
57
|
+
sql_seed = "sql seed"
|
|
58
|
+
|
|
59
|
+
# Mongo group commands
|
|
60
|
+
mongo_prepare = "mongo prepare"
|
|
61
|
+
mongo_setup_and_prepare = "mongo setup-and-prepare"
|
|
62
|
+
mongo_ping = "mongo ping"
|
|
63
|
+
mongo_scaffold = "mongo scaffold"
|
|
64
|
+
mongo_scaffold_documents = "mongo scaffold-documents"
|
|
65
|
+
mongo_scaffold_schemas = "mongo scaffold-schemas"
|
|
66
|
+
mongo_scaffold_resources = "mongo scaffold-resources"
|
|
67
|
+
|
|
68
|
+
# Observability group commands
|
|
69
|
+
obs_up = "obs up"
|
|
70
|
+
obs_down = "obs down"
|
|
71
|
+
obs_scaffold = "obs scaffold"
|
|
72
|
+
|
|
73
|
+
# Docs group
|
|
74
|
+
docs_help = "docs --help"
|
|
75
|
+
docs_show = "docs show"
|
|
76
|
+
|
|
77
|
+
# DX group
|
|
78
|
+
dx_openapi = "dx openapi"
|
|
79
|
+
dx_migrations = "dx migrations"
|
|
80
|
+
dx_changelog = "dx changelog"
|
|
81
|
+
dx_ci = "dx ci"
|
|
82
|
+
|
|
83
|
+
# Jobs group
|
|
84
|
+
jobs_run = "jobs run"
|
|
85
|
+
|
|
86
|
+
# SDK group
|
|
87
|
+
sdk_ts = "sdk ts"
|
|
88
|
+
sdk_py = "sdk py"
|
|
89
|
+
sdk_postman = "sdk postman"
|
|
48
90
|
|
|
49
91
|
|
|
50
92
|
async def svc_infra_subcmd_help(subcommand: Subcommand) -> dict:
|
|
@@ -52,7 +94,19 @@ async def svc_infra_subcmd_help(subcommand: Subcommand) -> dict:
|
|
|
52
94
|
Get help text for a specific subcommand of svc-infra CLI.
|
|
53
95
|
(Enum keeps a tight schema; function signature remains simple.)
|
|
54
96
|
"""
|
|
55
|
-
|
|
97
|
+
tokens = subcommand.value.split()
|
|
98
|
+
if len(tokens) == 1:
|
|
99
|
+
return await cli_subcmd_help(CLI_PROG, subcommand)
|
|
100
|
+
|
|
101
|
+
root = prepare_env()
|
|
102
|
+
text = await run_from_root(root, CLI_PROG, [*tokens, "--help"])
|
|
103
|
+
return {
|
|
104
|
+
"ok": True,
|
|
105
|
+
"action": "subcommand_help",
|
|
106
|
+
"subcommand": subcommand.value,
|
|
107
|
+
"project_root": str(root),
|
|
108
|
+
"help": text,
|
|
109
|
+
}
|
|
56
110
|
|
|
57
111
|
|
|
58
112
|
mcp = mcp_from_functions(
|
|
@@ -60,8 +114,11 @@ mcp = mcp_from_functions(
|
|
|
60
114
|
functions=[
|
|
61
115
|
svc_infra_cmd_help,
|
|
62
116
|
svc_infra_subcmd_help,
|
|
117
|
+
svc_infra_docs_help,
|
|
118
|
+
# Docs listing is available via 'docs --help'; no separate MCP function needed.
|
|
63
119
|
],
|
|
64
120
|
)
|
|
65
121
|
|
|
122
|
+
|
|
66
123
|
if __name__ == "__main__":
|
|
67
124
|
mcp.run(transport="stdio")
|
svc_infra/obs/add.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Callable, Iterable, Optional
|
|
3
|
+
from typing import Any, Callable, Iterable, Optional, Protocol
|
|
4
4
|
|
|
5
5
|
from svc_infra.obs.settings import ObservabilitySettings
|
|
6
6
|
|
|
@@ -9,12 +9,20 @@ def _want_metrics(cfg: ObservabilitySettings) -> bool:
|
|
|
9
9
|
return bool(cfg.METRICS_ENABLED)
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class RouteClassifier(Protocol):
|
|
13
|
+
def __call__(
|
|
14
|
+
self, route_path: str, method: str
|
|
15
|
+
) -> str: # e.g., returns "public|internal|admin"
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
|
|
12
19
|
def add_observability(
|
|
13
20
|
app: Any | None = None,
|
|
14
21
|
*,
|
|
15
22
|
db_engines: Optional[Iterable[Any]] = None,
|
|
16
23
|
metrics_path: str | None = None,
|
|
17
24
|
skip_metric_paths: Optional[Iterable[str]] = None,
|
|
25
|
+
route_classifier: RouteClassifier | None = None,
|
|
18
26
|
) -> Callable[[], None]:
|
|
19
27
|
"""
|
|
20
28
|
Enable Prometheus metrics for the ASGI app and optional SQLAlchemy pool metrics.
|
|
@@ -25,14 +33,53 @@ def add_observability(
|
|
|
25
33
|
# --- Metrics (Prometheus) — import lazily so CLIs/tests don’t require prometheus_client
|
|
26
34
|
if app is not None and _want_metrics(cfg):
|
|
27
35
|
try:
|
|
28
|
-
from svc_infra.obs.metrics.asgi import
|
|
36
|
+
from svc_infra.obs.metrics.asgi import ( # lazy
|
|
37
|
+
PrometheusMiddleware,
|
|
38
|
+
add_prometheus,
|
|
39
|
+
metrics_endpoint,
|
|
40
|
+
)
|
|
29
41
|
|
|
30
42
|
path = metrics_path or cfg.METRICS_PATH
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
skip_paths = tuple(skip_metric_paths or (path, "/health", "/healthz"))
|
|
44
|
+
# If a route_classifier is provided, use a custom route_resolver to append class label
|
|
45
|
+
if route_classifier is None:
|
|
46
|
+
add_prometheus(
|
|
47
|
+
app,
|
|
48
|
+
path=path,
|
|
49
|
+
skip_paths=skip_paths,
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
# Install middleware manually to pass route_resolver
|
|
53
|
+
def _resolver(req):
|
|
54
|
+
# Base template
|
|
55
|
+
from svc_infra.obs.metrics.asgi import _route_template # type: ignore
|
|
56
|
+
|
|
57
|
+
base = _route_template(req)
|
|
58
|
+
method = getattr(req, "method", "GET")
|
|
59
|
+
cls = route_classifier(base, method)
|
|
60
|
+
# Encode as base|class for downstream label splitting in dashboards
|
|
61
|
+
return f"{base}|{cls}"
|
|
62
|
+
|
|
63
|
+
app.add_middleware(
|
|
64
|
+
PrometheusMiddleware,
|
|
65
|
+
skip_paths=skip_paths,
|
|
66
|
+
route_resolver=_resolver,
|
|
67
|
+
)
|
|
68
|
+
# Mount /metrics endpoint without re-adding middleware
|
|
69
|
+
try:
|
|
70
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
71
|
+
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
|
|
72
|
+
|
|
73
|
+
router = public_router()
|
|
74
|
+
router.add_api_route(
|
|
75
|
+
path,
|
|
76
|
+
endpoint=metrics_endpoint(),
|
|
77
|
+
include_in_schema=CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV),
|
|
78
|
+
tags=["observability"],
|
|
79
|
+
)
|
|
80
|
+
app.include_router(router)
|
|
81
|
+
except Exception:
|
|
82
|
+
app.add_route(path, metrics_endpoint())
|
|
36
83
|
except Exception:
|
|
37
84
|
pass
|
|
38
85
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Service HTTP Overview",
|
|
3
|
+
"tags": ["svc-infra", "http"],
|
|
4
|
+
"timezone": "browser",
|
|
5
|
+
"panels": [
|
|
6
|
+
{
|
|
7
|
+
"type": "timeseries",
|
|
8
|
+
"title": "Success Rate (5m)",
|
|
9
|
+
"targets": [
|
|
10
|
+
{
|
|
11
|
+
"expr": "sum(rate(http_server_requests_total{code!~\"5..\"}[5m])) / sum(rate(http_server_requests_total[5m]))",
|
|
12
|
+
"legendFormat": "success_rate"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "timeseries",
|
|
18
|
+
"title": "Latency p99",
|
|
19
|
+
"targets": [
|
|
20
|
+
{
|
|
21
|
+
"expr": "histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket[5m])) by (le))",
|
|
22
|
+
"legendFormat": "p99"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"type": "table",
|
|
28
|
+
"title": "Top Routes by Error (5m)",
|
|
29
|
+
"targets": [
|
|
30
|
+
{
|
|
31
|
+
"expr": "topk(10, sum(rate(http_server_requests_total{code=~\"5..\"}[5m])) by (route))",
|
|
32
|
+
"legendFormat": "{{route}}"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"templating": {
|
|
38
|
+
"list": []
|
|
39
|
+
},
|
|
40
|
+
"time": {
|
|
41
|
+
"from": "now-6h",
|
|
42
|
+
"to": "now"
|
|
43
|
+
},
|
|
44
|
+
"refresh": "30s"
|
|
45
|
+
}
|
svc_infra/security/headers.py
CHANGED
|
@@ -6,8 +6,21 @@ SECURE_DEFAULTS = {
|
|
|
6
6
|
"X-Frame-Options": "DENY",
|
|
7
7
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
8
8
|
"X-XSS-Protection": "0",
|
|
9
|
-
# CSP
|
|
10
|
-
|
|
9
|
+
# CSP with practical defaults - allows inline styles/scripts and data URIs for images
|
|
10
|
+
# Also allows cdn.jsdelivr.net for FastAPI docs (Swagger UI, ReDoc)
|
|
11
|
+
# Still secure: blocks arbitrary external scripts, prevents framing, restricts form actions
|
|
12
|
+
# Override via headers_overrides in add_security() for stricter or custom policies
|
|
13
|
+
"Content-Security-Policy": (
|
|
14
|
+
"default-src 'self'; "
|
|
15
|
+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
|
16
|
+
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
|
17
|
+
"img-src 'self' data: https:; "
|
|
18
|
+
"connect-src 'self'; "
|
|
19
|
+
"font-src 'self' https://cdn.jsdelivr.net; "
|
|
20
|
+
"frame-ancestors 'none'; "
|
|
21
|
+
"base-uri 'self'; "
|
|
22
|
+
"form-action 'self'"
|
|
23
|
+
),
|
|
11
24
|
}
|
|
12
25
|
|
|
13
26
|
|
svc_infra/security/hibp.py
CHANGED
|
@@ -5,7 +5,7 @@ import time
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from typing import Dict, Optional
|
|
7
7
|
|
|
8
|
-
import
|
|
8
|
+
from svc_infra.http import new_httpx_client
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def sha1_hex(data: str) -> str:
|
|
@@ -39,7 +39,11 @@ class HIBPClient:
|
|
|
39
39
|
self.timeout = timeout
|
|
40
40
|
self.user_agent = user_agent
|
|
41
41
|
self._cache: Dict[str, CacheEntry] = {}
|
|
42
|
-
|
|
42
|
+
# Use central factory for consistent defaults; retain explicit timeout override
|
|
43
|
+
self._http = new_httpx_client(
|
|
44
|
+
timeout_seconds=self.timeout,
|
|
45
|
+
headers={"User-Agent": self.user_agent},
|
|
46
|
+
)
|
|
43
47
|
|
|
44
48
|
def _get_cached(self, prefix: str) -> Optional[str]:
|
|
45
49
|
now = time.time()
|
svc_infra/security/models.py
CHANGED
|
@@ -6,7 +6,17 @@ import uuid
|
|
|
6
6
|
from datetime import datetime, timedelta, timezone
|
|
7
7
|
from typing import Optional
|
|
8
8
|
|
|
9
|
-
from sqlalchemy import
|
|
9
|
+
from sqlalchemy import (
|
|
10
|
+
JSON,
|
|
11
|
+
Boolean,
|
|
12
|
+
DateTime,
|
|
13
|
+
ForeignKey,
|
|
14
|
+
Index,
|
|
15
|
+
String,
|
|
16
|
+
Text,
|
|
17
|
+
UniqueConstraint,
|
|
18
|
+
text,
|
|
19
|
+
)
|
|
10
20
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
11
21
|
|
|
12
22
|
from svc_infra.db.sql.base import ModelBase
|
|
@@ -34,7 +44,7 @@ class AuthSession(ModelBase):
|
|
|
34
44
|
)
|
|
35
45
|
|
|
36
46
|
created_at = mapped_column(
|
|
37
|
-
DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
|
|
47
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
38
48
|
)
|
|
39
49
|
|
|
40
50
|
|
|
@@ -54,7 +64,7 @@ class RefreshToken(ModelBase):
|
|
|
54
64
|
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
|
|
55
65
|
|
|
56
66
|
created_at = mapped_column(
|
|
57
|
-
DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
|
|
67
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
58
68
|
)
|
|
59
69
|
|
|
60
70
|
__table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
|
|
@@ -126,7 +136,7 @@ class Organization(ModelBase):
|
|
|
126
136
|
slug: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
127
137
|
tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
128
138
|
created_at = mapped_column(
|
|
129
|
-
DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
|
|
139
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
130
140
|
)
|
|
131
141
|
|
|
132
142
|
|
|
@@ -139,7 +149,7 @@ class Team(ModelBase):
|
|
|
139
149
|
)
|
|
140
150
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
141
151
|
created_at = mapped_column(
|
|
142
|
-
DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
|
|
152
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
143
153
|
)
|
|
144
154
|
|
|
145
155
|
|
|
@@ -155,7 +165,7 @@ class OrganizationMembership(ModelBase):
|
|
|
155
165
|
)
|
|
156
166
|
role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
157
167
|
created_at = mapped_column(
|
|
158
|
-
DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
|
|
168
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
159
169
|
)
|
|
160
170
|
deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
161
171
|
|
|
@@ -177,7 +187,7 @@ class OrganizationInvitation(ModelBase):
|
|
|
177
187
|
GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
|
|
178
188
|
)
|
|
179
189
|
created_at = mapped_column(
|
|
180
|
-
DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
|
|
190
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
181
191
|
)
|
|
182
192
|
last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
183
193
|
resend_count: Mapped[int] = mapped_column(default=0)
|
|
@@ -185,6 +195,11 @@ class OrganizationInvitation(ModelBase):
|
|
|
185
195
|
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
186
196
|
|
|
187
197
|
|
|
198
|
+
# ------------------------ OAuth Provider Accounts -----------------------------
|
|
199
|
+
# MOVED to svc_infra.security.oauth_models for opt-in OAuth support
|
|
200
|
+
# Projects that enable OAuth should import ProviderAccount from there
|
|
201
|
+
|
|
202
|
+
|
|
188
203
|
# ------------------------ Utilities -------------------------------------------
|
|
189
204
|
|
|
190
205
|
|
|
@@ -238,6 +253,11 @@ __all__ = [
|
|
|
238
253
|
"FailedAuthAttempt",
|
|
239
254
|
"RolePermission",
|
|
240
255
|
"AuditLog",
|
|
256
|
+
"Organization",
|
|
257
|
+
"Team",
|
|
258
|
+
"OrganizationMembership",
|
|
259
|
+
"OrganizationInvitation",
|
|
260
|
+
# ProviderAccount moved to svc_infra.security.oauth_models (opt-in)
|
|
241
261
|
"generate_refresh_token",
|
|
242
262
|
"hash_refresh_token",
|
|
243
263
|
"compute_audit_hash",
|