Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ PROJECT_NAME=Sentinel
API_V1_PREFIX=/api/v1
BACKEND_CORS_ORIGINS=["http://localhost:3000"]

# --- Logging ---
# LOG_LEVEL: DEBUG | INFO | WARNING | ERROR | CRITICAL
LOG_LEVEL=INFO
# LOG_FORMAT: "text" for human-readable console output, "json" for structured
# single-line logs suited to log aggregators (recommended in production).
LOG_FORMAT=text

# --- Error tracking (optional) ---
# Leave SENTRY_DSN unset to disable Sentry entirely (default for local dev/tests).
SENTRY_DSN=
SENTRY_TRACES_SAMPLE_RATE=0.0

# --- Security / JWT ---
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))"
SECRET_KEY=change-me-to-a-long-random-secret
Expand All @@ -19,6 +31,13 @@ POSTGRES_HOST=localhost
POSTGRES_PORT=5432
DATABASE_URL=postgresql+asyncpg://sentinel:sentinel@localhost:5432/sentinel

# SQLAlchemy async engine connection pool tuning.
DB_POOL_SIZE=5
DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=30
DB_POOL_RECYCLE_SECONDS=1800
DB_POOL_PRE_PING=true

# Used by the test suite (separate database)
TEST_DATABASE_URL=postgresql+asyncpg://sentinel:sentinel@localhost:5432/sentinel_test

Expand All @@ -28,3 +47,23 @@ REDIS_URL=redis://localhost:6379/0
# false when running real worker/beat processes against Redis.
CELERY_TASK_ALWAYS_EAGER=true
CHECK_DISPATCH_INTERVAL_SECONDS=10

# --- SMTP (Phase 5: email notifications) ---
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USERNAME=apikey
# SMTP_PASSWORD=your-smtp-password
# SMTP_USE_TLS=true
# SMTP_FROM_ADDRESS=alerts@yourdomain.com

# --- Production deployment (Render + Vercel) ---
# For Render:
# DATABASE_URL, REDIS_URL, SECRET_KEY are set automatically by render.yaml
# ENVIRONMENT=production
# LOG_FORMAT=json
# BACKEND_CORS_ORIGINS=["https://your-app.vercel.app"]
# CELERY_TASK_ALWAYS_EAGER=false
#
# For Vercel (frontend):
# NEXT_PUBLIC_API_URL=https://your-api.onrender.com/api/v1

8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ htmlcov/
.idea/
.vscode/

# Frontend (Next.js)
frontend/node_modules/
frontend/.next/
frontend/out/

# Celery beat schedule (local dev)
celerybeat-schedule*

# OS
.DS_Store
Thumbs.db
32 changes: 17 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ Stop it:
& "C:\Program Files\PostgreSQL\17\bin\pg_ctl.exe" -D "C:\Users\saran\pgdata\sentinel" stop
```

## Configuration

In addition to the database/Redis/JWT settings in `.env.example`, the
following are configurable (see `app/core/config.py` for defaults):

| Variable | Default | Purpose |
|---|---|---|
| `LOG_LEVEL` | `INFO` | Root logger level (`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`). |
| `LOG_FORMAT` | `text` | `text` for human-readable console logs, `json` for single-line structured logs suited to log aggregators. |
| `SENTRY_DSN` | unset | Enables Sentry error tracking (FastAPI + Celery) when set. No-op when unset. |
| `SENTRY_TRACES_SAMPLE_RATE` | `0.0` | Fraction of requests/tasks to trace when Sentry is enabled. |
| `DB_POOL_SIZE` | `5` | SQLAlchemy async engine connection pool size. |
| `DB_MAX_OVERFLOW` | `10` | Extra connections allowed beyond `DB_POOL_SIZE` under load. |
| `DB_POOL_TIMEOUT` | `30` | Seconds to wait for a pooled connection before raising. |
| `DB_POOL_RECYCLE_SECONDS` | `1800` | Recycle connections older than this, to avoid stale connections on managed Postgres. |
| `DB_POOL_PRE_PING` | `true` | Test connections with a lightweight ping before use. |

## Database migrations

```bash
Expand Down Expand Up @@ -143,21 +160,6 @@ fixture credential), regenerate the baseline locally and commit it:
detect-secrets scan > .secrets.baseline
```

### Known issue: pre-existing dependency vulnerabilities

`pip-audit` currently reports 4 known vulnerabilities, so the `Security
checks` job fails on `main` until these are addressed in a dedicated pass:

- `pytest==8.3.4` — fix is `9.0.3` (major version bump; needs a compatibility
pass with `pytest-asyncio`/`pytest-cov`/`respx` before upgrading).
- `starlette==0.41.3` (transitive, via `fastapi==0.115.6`) — 3 CVEs, fixes
require `starlette>=0.47`/`1.0.1`, which likely means a `fastapi` major
bump and a full regression pass of the custom middleware stack
(`app/core/security_headers.py`, `app/core/rate_limit.py`, CORS config).

Tracked as a follow-up; not addressed as part of the CI/CD setup to avoid
bundling a risky framework upgrade with the pipeline rollout.

## API overview (Phase 1)

| Method & Path | Auth | Description |
Expand Down
5 changes: 5 additions & 0 deletions app/core/celery_app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from celery import Celery

from app.core.config import settings
from app.core.logging import configure_logging
from app.core.sentry import configure_sentry

configure_logging()
configure_sentry()

celery_app = Celery(
"sentinel",
Expand Down
15 changes: 15 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from functools import lru_cache
from typing import Literal

from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
Expand All @@ -16,6 +17,13 @@ class Settings(BaseSettings):
PROJECT_NAME: str = "Sentinel"
API_V1_PREFIX: str = "/api/v1"

LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
LOG_FORMAT: Literal["text", "json"] = "text"

# Error tracking (optional). Leave unset to disable Sentry entirely.
SENTRY_DSN: str | None = None
SENTRY_TRACES_SAMPLE_RATE: float = 0.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add bounds validation for new numeric runtime settings.

Line 25 and Lines 38-41 accept unconstrained numeric values from env, so invalid values can slip through and break startup/runtime behavior instead of failing fast at config load.

Suggested fix
-from pydantic import field_validator, model_validator
+from pydantic import Field, field_validator, model_validator
@@
-    SENTRY_TRACES_SAMPLE_RATE: float = 0.0
+    SENTRY_TRACES_SAMPLE_RATE: float = Field(default=0.0, ge=0.0, le=1.0)
@@
-    DB_POOL_SIZE: int = 5
-    DB_MAX_OVERFLOW: int = 10
-    DB_POOL_TIMEOUT: int = 30
-    DB_POOL_RECYCLE_SECONDS: int = 1800
+    DB_POOL_SIZE: int = Field(default=5, ge=1)
+    DB_MAX_OVERFLOW: int = Field(default=10, ge=0)
+    DB_POOL_TIMEOUT: int = Field(default=30, ge=1)
+    DB_POOL_RECYCLE_SECONDS: int = Field(default=1800, ge=0)

Also applies to: 38-42

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/core/config.py` at line 25, SENTRY_TRACES_SAMPLE_RATE (and the other
unconstrained numeric runtime settings around lines 38-41) must be validated at
config load; add explicit bounds checks (e.g., for sample rates enforce 0.0 <=
SENTRY_TRACES_SAMPLE_RATE <= 1.0, and apply sensible min/max for any other
numeric settings) in the config initialization path (e.g., __post_init__ of the
Config/dataclass or pydantic validators) and raise a clear ValueError with the
variable name and invalid value if the check fails so startup fails fast with a
descriptive message.


BACKEND_CORS_ORIGINS: list[str] = []

SECRET_KEY: str
Expand All @@ -26,6 +34,13 @@ class Settings(BaseSettings):
DATABASE_URL: str
TEST_DATABASE_URL: str | None = None

# SQLAlchemy async engine connection pool tuning.
DB_POOL_SIZE: int = 5
DB_MAX_OVERFLOW: int = 10
DB_POOL_TIMEOUT: int = 30
DB_POOL_RECYCLE_SECONDS: int = 1800
DB_POOL_PRE_PING: bool = True

POSTGRES_USER: str = "sentinel"
POSTGRES_PASSWORD: str = "sentinel"
POSTGRES_DB: str = "sentinel"
Expand Down
54 changes: 54 additions & 0 deletions app/core/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import json
import logging
import sys
from datetime import UTC, datetime
from typing import Any

from app.core.config import settings

_configured = False


class JsonFormatter(logging.Formatter):
"""Render log records as single-line JSON for ingestion by log aggregators."""

def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
payload["exc_info"] = self.formatException(record.exc_info)
return json.dumps(payload)


def configure_logging() -> None:
"""Configure the root logger's level and output format.

Called once at process startup (FastAPI app and Celery workers/beat) so that
`logging.getLogger(__name__)` calls throughout the codebase - e.g. the Redis
fail-open warnings in `app.services.cache` and `app.core.redis` - are emitted
with a consistent, timestamped format at the configured level.
"""
global _configured
if _configured:
return

handler = logging.StreamHandler(sys.stdout)
if settings.LOG_FORMAT == "json":
handler.setFormatter(JsonFormatter())
else:
handler.setFormatter(
logging.Formatter(
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S%z",
)
)

root_logger = logging.getLogger()
root_logger.setLevel(settings.LOG_LEVEL)
root_logger.addHandler(handler)

Comment on lines +50 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid unconditional root handler append (duplicate log emission risk).

At Line 52, root_logger.addHandler(handler) always adds another handler even when a root stream handler already exists, which can duplicate every log record in real runtimes that preconfigure logging.

Proposed fix
 def configure_logging() -> None:
@@
     root_logger = logging.getLogger()
     root_logger.setLevel(settings.LOG_LEVEL)
-    root_logger.addHandler(handler)
+    # Replace existing root handlers to ensure a single, deterministic format.
+    root_logger.handlers.clear()
+    root_logger.addHandler(handler)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
root_logger = logging.getLogger()
root_logger.setLevel(settings.LOG_LEVEL)
root_logger.addHandler(handler)
root_logger = logging.getLogger()
root_logger.setLevel(settings.LOG_LEVEL)
# Replace existing root handlers to ensure a single, deterministic format.
root_logger.handlers.clear()
root_logger.addHandler(handler)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/core/logging.py` around lines 50 - 53, The root logger unconditionally
calls root_logger.addHandler(handler) which can duplicate logs; update the
initialization in app/core/logging.py to detect existing equivalent handlers
before adding: inspect logging.getLogger()'s handlers (root_logger.handlers) and
only add the new handler if there is no existing handler of the same type and
configuration (e.g., same class or same stream/formatter), so modify the logic
around root_logger, handler, addHandler and settings.LOG_LEVEL to perform this
presence check and skip addHandler when an equivalent handler is already
attached.

_configured = True
2 changes: 1 addition & 1 deletion app/core/security_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
too_large = False
if too_large:
return JSONResponse(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
content={"detail": "Request body too large."},
)

Expand Down
23 changes: 23 additions & 0 deletions app/core/sentry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import sentry_sdk

from app.core.config import settings

_configured = False


def configure_sentry() -> None:
"""Initialize Sentry error tracking if `SENTRY_DSN` is configured.

No-op when `SENTRY_DSN` is unset, which is the default for local
development and the test suite.
"""
global _configured
if _configured or not settings.SENTRY_DSN:
return

sentry_sdk.init(
dsn=settings.SENTRY_DSN,
environment=settings.ENVIRONMENT,
traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE,
)
_configured = True
5 changes: 5 additions & 0 deletions app/db/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
settings.DATABASE_URL,
echo=settings.ENVIRONMENT == "development",
future=True,
pool_size=settings.DB_POOL_SIZE,
max_overflow=settings.DB_MAX_OVERFLOW,
pool_timeout=settings.DB_POOL_TIMEOUT,
pool_recycle=settings.DB_POOL_RECYCLE_SECONDS,
pool_pre_ping=settings.DB_POOL_PRE_PING,
)

AsyncSessionLocal = async_sessionmaker(
Expand Down
7 changes: 6 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@
PermissionDeniedError,
ValidationError,
)
from app.core.logging import configure_logging
from app.core.rate_limit import RateLimitMiddleware
from app.core.security_headers import SecurityHeadersMiddleware
from app.core.sentry import configure_sentry

configure_logging()
configure_sentry()


def register_exception_handlers(app: FastAPI) -> None:
Expand All @@ -31,7 +36,7 @@ async def permission_denied_handler(request: Request, exc: PermissionDeniedError

@app.exception_handler(ValidationError)
async def validation_error_handler(request: Request, exc: ValidationError) -> JSONResponse:
return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={"detail": str(exc)})
return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": str(exc)})

@app.exception_handler(AuthenticationError)
async def authentication_error_handler(request: Request, exc: AuthenticationError) -> JSONResponse:
Expand Down
99 changes: 99 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
services:
- type: web
name: sentinel-api
runtime: python
buildCommand: pip install -r requirements.txt && alembic upgrade head
startCommand: uvicorn app.main:app --host 0.0.0.0 --port $PORT
envVars:
- key: ENVIRONMENT
value: production
- key: LOG_FORMAT
value: json
- key: LOG_LEVEL
value: INFO
- key: SECRET_KEY
generateValue: true
- key: DATABASE_URL
fromDatabase:
name: sentinel-db
property: connectionURI
- key: REDIS_URL
fromService:
type: redis
name: sentinel-redis
property: connectionURI
Comment on lines +20 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

sentinel-redis is referenced but not declared in this blueprint.

Lines 20-24, 52-56, and 81-85 all depend on a Redis service named sentinel-redis, but this manifest does not define one. That creates a deployment-time wiring failure for all three services.

Suggested fix
 services:
@@
   - type: worker
     name: sentinel-beat
@@
       - key: PYTHON_VERSION
         value: "3.12.0"
+
+  - type: redis
+    name: sentinel-redis
+    plan: free
+    ipAllowList: []

Also applies to: 52-56, 81-85

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@render.yaml` around lines 20 - 24, The manifest references a Redis service
named "sentinel-redis" in the REDIS_URL fromService blocks (type: redis,
property: connectionURI) but that service is not declared; either add a service
declaration for sentinel-redis with the expected redis/sentinel configuration or
update each fromService reference (lines using REDIS_URL) to point to an
existing declared Redis service name; ensure the service name you choose matches
all uses (the three fromService entries) and provides the connectionURI property
expected by the consumers.

- key: CELERY_TASK_ALWAYS_EAGER
value: "false"
- key: BACKEND_CORS_ORIGINS
value: '["https://sentinel-dashboard.vercel.app"]'
- key: PYTHON_VERSION
value: "3.12.0"
healthCheckPath: /health

- type: worker
name: sentinel-worker
runtime: python
buildCommand: pip install -r requirements.txt
startCommand: celery -A app.core.celery_app worker --loglevel=info --concurrency=2
envVars:
- key: ENVIRONMENT
value: production
- key: LOG_FORMAT
value: json
- key: SECRET_KEY
fromService:
type: web
name: sentinel-api
envVarKey: SECRET_KEY
- key: DATABASE_URL
fromDatabase:
name: sentinel-db
property: connectionURI
- key: REDIS_URL
fromService:
type: redis
name: sentinel-redis
property: connectionURI
- key: CELERY_TASK_ALWAYS_EAGER
value: "false"
- key: PYTHON_VERSION
value: "3.12.0"

- type: worker
name: sentinel-beat
runtime: python
buildCommand: pip install -r requirements.txt
startCommand: celery -A app.core.celery_app beat --loglevel=info
envVars:
- key: ENVIRONMENT
value: production
- key: LOG_FORMAT
value: json
- key: SECRET_KEY
fromService:
type: web
name: sentinel-api
envVarKey: SECRET_KEY
- key: DATABASE_URL
fromDatabase:
name: sentinel-db
property: connectionURI
- key: REDIS_URL
fromService:
type: redis
name: sentinel-redis
property: connectionURI
- key: CELERY_TASK_ALWAYS_EAGER
value: "false"
- key: PYTHON_VERSION
value: "3.12.0"

databases:
- name: sentinel-db
plan: free
databaseName: sentinel
user: sentinel

# Note: Render free Redis is available in some regions.
# If not available, set CELERY_TASK_ALWAYS_EAGER=true on the web service
# and remove the worker/beat services.
4 changes: 2 additions & 2 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
-r requirements.txt

pytest==8.3.4
pytest-asyncio==0.25.0
pytest==9.0.3
pytest-asyncio==1.4.0
pytest-cov==6.0.0
pytest-timeout==2.4.0
respx==0.22.0
Expand Down
Loading
Loading