Skip to content
Merged
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
16 changes: 12 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ repos:
# Local hooks using uv run to match CI exactly
- repo: local
hooks:
# Ruff - matches CI: cd backend && uv run ruff check . --config pyproject.toml
# Ruff format - auto-fix formatting
- id: ruff-format-backend
name: ruff format (backend)
entry: bash -c 'cd backend && uv run ruff format --config pyproject.toml .'
language: system
files: ^backend/.*\.py$
pass_filenames: false

# Ruff check - auto-fix safe lint issues (import sorting, etc.)
- id: ruff-backend
name: ruff check (backend)
entry: bash -c 'cd backend && uv run ruff check . --config pyproject.toml'
entry: bash -c 'cd backend && uv run ruff check --fix --config pyproject.toml .'
language: system
files: ^backend/.*\.py$
pass_filenames: false
Expand Down Expand Up @@ -46,10 +54,10 @@ repos:
files: ^frontend/src/.*\.css$
pass_filenames: false

# Prettier - matches CI: cd frontend && npx prettier --check
# Prettier - auto-fix formatting
- id: prettier-frontend
name: prettier (frontend)
entry: bash -c 'cd frontend && npx prettier --check "src/**/*.{ts,svelte,json}"'
entry: bash -c 'cd frontend && npx prettier --write "src/**/*.{ts,svelte,json}"'
language: system
files: ^frontend/src/.*\.(ts|svelte|json)$
pass_filenames: false
Expand Down
6 changes: 5 additions & 1 deletion backend/app/api/routes/admin/executions.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ async def list_executions(
skip: int = Query(0, ge=0),
) -> AdminExecutionListResponse:
executions, total = await service.list_executions(
status=status, priority=priority, user_id=user_id, limit=limit, skip=skip,
status=status,
priority=priority,
user_id=user_id,
limit=limit,
skip=skip,
)
return AdminExecutionListResponse(
executions=[AdminExecutionResponse.model_validate(e) for e in executions],
Expand Down
4 changes: 1 addition & 3 deletions backend/app/api/routes/admin/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,7 @@ async def delete_user(
if admin.user_id == user_id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")

result = await admin_user_service.delete_user(
admin_user_id=admin.user_id, user_id=user_id, cascade=cascade
)
result = await admin_user_service.delete_user(admin_user_id=admin.user_id, user_id=user_id, cascade=cascade)
return DeleteUserResponse.model_validate(result)


Expand Down
86 changes: 44 additions & 42 deletions backend/app/api/routes/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@

@inject
async def get_execution_with_access(
execution_id: Annotated[str, Path()],
current_user: Annotated[User, Depends(current_user)],
execution_service: FromDishka[ExecutionService],
execution_id: Annotated[str, Path()],
current_user: Annotated[User, Depends(current_user)],
execution_service: FromDishka[ExecutionService],
) -> ExecutionInDB:
domain_exec = await execution_service.get_execution_result(execution_id)

Expand All @@ -50,22 +50,24 @@ async def get_execution_with_access(
responses={500: {"model": ErrorResponse, "description": "Script execution failed"}},
)
async def create_execution(
request: Request,
current_user: Annotated[User, Depends(current_user)],
execution: ExecutionRequest,
execution_service: FromDishka[ExecutionService],
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
request: Request,
current_user: Annotated[User, Depends(current_user)],
execution: ExecutionRequest,
execution_service: FromDishka[ExecutionService],
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
) -> ExecutionResponse:
"""Submit a script for execution in an isolated Kubernetes pod."""
trace.get_current_span().set_attributes({
"http.method": "POST",
"http.route": "/api/v1/execute",
"execution.language": execution.lang,
"execution.language_version": execution.lang_version,
"execution.script_length": len(execution.script),
"user.id": current_user.user_id,
"client.address": get_client_ip(request),
})
trace.get_current_span().set_attributes(
{
"http.method": "POST",
"http.route": "/api/v1/execute",
"execution.language": execution.lang,
"execution.language_version": execution.lang_version,
"execution.script_length": len(execution.script),
"user.id": current_user.user_id,
"client.address": get_client_ip(request),
}
)

exec_result = await execution_service.execute_script_idempotent(
script=execution.script,
Expand All @@ -83,7 +85,7 @@ async def create_execution(
responses={403: {"model": ErrorResponse, "description": "Not the owner of this execution"}},
)
async def get_result(
execution: Annotated[ExecutionInDB, Depends(get_execution_with_access)],
execution: Annotated[ExecutionInDB, Depends(get_execution_with_access)],
) -> ExecutionResult:
"""Retrieve the result of a specific execution."""
return ExecutionResult.model_validate(execution)
Expand All @@ -98,10 +100,10 @@ async def get_result(
},
)
async def cancel_execution(
execution: Annotated[ExecutionInDB, Depends(get_execution_with_access)],
current_user: Annotated[User, Depends(current_user)],
cancel_request: CancelExecutionRequest,
execution_service: FromDishka[ExecutionService],
execution: Annotated[ExecutionInDB, Depends(get_execution_with_access)],
current_user: Annotated[User, Depends(current_user)],
cancel_request: CancelExecutionRequest,
execution_service: FromDishka[ExecutionService],
) -> CancelResponse:
"""Cancel a running or queued execution."""
result = await execution_service.cancel_execution(
Expand All @@ -122,9 +124,9 @@ async def cancel_execution(
},
)
async def retry_execution(
original_execution: Annotated[ExecutionInDB, Depends(get_execution_with_access)],
current_user: Annotated[User, Depends(current_user)],
execution_service: FromDishka[ExecutionService],
original_execution: Annotated[ExecutionInDB, Depends(get_execution_with_access)],
current_user: Annotated[User, Depends(current_user)],
execution_service: FromDishka[ExecutionService],
) -> ExecutionResponse:
"""Retry a failed or completed execution."""

Expand All @@ -146,10 +148,10 @@ async def retry_execution(
responses={403: {"model": ErrorResponse, "description": "Not the owner of this execution"}},
)
async def get_execution_events(
execution: Annotated[ExecutionInDB, Depends(get_execution_with_access)],
event_service: FromDishka[EventService],
event_types: Annotated[list[EventType] | None, Query(description="Event types to filter")] = None,
limit: Annotated[int, Query(ge=1, le=1000)] = 100,
execution: Annotated[ExecutionInDB, Depends(get_execution_with_access)],
event_service: FromDishka[EventService],
event_types: Annotated[list[EventType] | None, Query(description="Event types to filter")] = None,
limit: Annotated[int, Query(ge=1, le=1000)] = 100,
) -> list[DomainEvent]:
"""Get all events for an execution."""
events = await event_service.get_events_by_execution_id(
Expand All @@ -160,14 +162,14 @@ async def get_execution_events(

@router.get("/user/executions", response_model=ExecutionListResponse)
async def get_user_executions(
current_user: Annotated[User, Depends(current_user)],
execution_service: FromDishka[ExecutionService],
status: Annotated[ExecutionStatus | None, Query(description="Filter by execution status")] = None,
lang: Annotated[str | None, Query(description="Filter by programming language")] = None,
start_time: Annotated[datetime | None, Query(description="Filter executions created after this time")] = None,
end_time: Annotated[datetime | None, Query(description="Filter executions created before this time")] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
skip: Annotated[int, Query(ge=0)] = 0,
current_user: Annotated[User, Depends(current_user)],
execution_service: FromDishka[ExecutionService],
status: Annotated[ExecutionStatus | None, Query(description="Filter by execution status")] = None,
lang: Annotated[str | None, Query(description="Filter by programming language")] = None,
start_time: Annotated[datetime | None, Query(description="Filter executions created after this time")] = None,
end_time: Annotated[datetime | None, Query(description="Filter executions created before this time")] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
skip: Annotated[int, Query(ge=0)] = 0,
) -> ExecutionListResponse:
"""Get executions for the current user."""

Expand All @@ -194,7 +196,7 @@ async def get_user_executions(

@router.get("/example-scripts", response_model=ExampleScripts)
async def get_example_scripts(
execution_service: FromDishka[ExecutionService],
execution_service: FromDishka[ExecutionService],
) -> ExampleScripts:
"""Get example scripts for the code editor."""
scripts = await execution_service.get_example_scripts()
Expand All @@ -203,7 +205,7 @@ async def get_example_scripts(

@router.get("/k8s-limits", response_model=ResourceLimits)
async def get_k8s_resource_limits(
execution_service: FromDishka[ExecutionService],
execution_service: FromDishka[ExecutionService],
) -> ResourceLimits:
"""Get Kubernetes resource limits for script execution."""
limits = await execution_service.get_k8s_resource_limits()
Expand All @@ -212,9 +214,9 @@ async def get_k8s_resource_limits(

@router.delete("/executions/{execution_id}", response_model=DeleteResponse)
async def delete_execution(
execution_id: str,
admin: Annotated[User, Depends(admin_user)],
execution_service: FromDishka[ExecutionService],
execution_id: str,
admin: Annotated[User, Depends(admin_user)],
execution_service: FromDishka[ExecutionService],
) -> DeleteResponse:
"""Delete an execution and its associated data (admin only)."""
await execution_service.delete_execution(execution_id, admin.user_id)
Expand Down
4 changes: 3 additions & 1 deletion backend/app/core/dishka_lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:

await container.get(Tracer)
FastAPIInstrumentor().instrument_app(
app, tracer_provider=trace.get_tracer_provider(), excluded_urls="health,metrics,docs,openapi.json",
app,
tracer_provider=trace.get_tracer_provider(),
excluded_urls="health,metrics,docs,openapi.json",
)
logger.info("FastAPI OpenTelemetry instrumentation applied")

Expand Down
2 changes: 2 additions & 0 deletions backend/app/core/exceptions/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,6 @@ def _map_to_status_code(exc: DomainError) -> int:
if isinstance(exc, InfrastructureError):
return 500
return 500


# --8<-- [end:configure_exception_handlers]
18 changes: 11 additions & 7 deletions backend/app/core/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ def setup_logger(log_level: str) -> structlog.stdlib.BoundLogger:

logger: structlog.stdlib.BoundLogger = structlog.get_logger("integr8scode")
return logger


# --8<-- [end:setup_logger]


Expand All @@ -122,13 +124,15 @@ def setup_log_exporter(settings: Settings, logger: structlog.stdlib.BoundLogger)
if not settings.OTEL_EXPORTER_OTLP_ENDPOINT:
return

resource = Resource.create({
SERVICE_NAME: settings.SERVICE_NAME,
SERVICE_VERSION: settings.SERVICE_VERSION,
"service.namespace": "integr8scode",
"deployment.environment": settings.ENVIRONMENT,
"service.instance.id": settings.HOSTNAME,
})
resource = Resource.create(
{
SERVICE_NAME: settings.SERVICE_NAME,
SERVICE_VERSION: settings.SERVICE_VERSION,
"service.namespace": "integr8scode",
"deployment.environment": settings.ENVIRONMENT,
"service.instance.id": settings.HOSTNAME,
}
)

endpoint = settings.OTEL_EXPORTER_OTLP_ENDPOINT
log_exporter = OTLPLogExporter(
Expand Down
1 change: 1 addition & 0 deletions backend/app/core/metrics/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def __init__(self, settings: Settings, meter_name: str | None = None):
meter_name = meter_name or self.__class__.__name__
self._meter = metrics.get_meter(meter_name)
self._create_instruments()

# --8<-- [end:init]

def _create_instruments(self) -> None:
Expand Down
1 change: 0 additions & 1 deletion backend/app/core/metrics/dlq.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,3 @@ def record_dlq_processing_error(self, original_topic: str, event_type: str, erro
self.dlq_processing_errors.add(
1, attributes={"original_topic": original_topic, "event_type": event_type, "error_type": error_type}
)

3 changes: 3 additions & 0 deletions backend/app/core/middlewares/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def _get_path_template(path: str) -> str:
path = re.sub(r"/[0-9a-f]{24}", "/{id}", path)

return path

# --8<-- [end:path_template]


Expand Down Expand Up @@ -208,4 +209,6 @@ def get_process_metrics(_: CallbackOptions) -> list[Observation]:
meter.create_observable_gauge(
name="process_metrics", callbacks=[get_process_metrics], description="Process-level metrics", unit="mixed"
)


# --8<-- [end:system_metrics]
1 change: 1 addition & 0 deletions backend/app/core/middlewares/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ def _extract_user_id(request: Request) -> str:
craft arbitrary bucket keys to bypass IP-based limits.
"""
return f"ip:{get_client_ip(request)}"

# --8<-- [end:extract_user_id]

async def _check_rate_limit(self, user_id: str, endpoint: str) -> RateLimitStatus:
Expand Down
1 change: 1 addition & 0 deletions backend/app/core/middlewares/request_size_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class RequestSizeLimitMiddleware:
def __init__(self, app: ASGIApp, max_size_mb: int = 10) -> None:
self.app = app
self.max_size_bytes = max_size_mb * 1024 * 1024

# --8<-- [end:RequestSizeLimitMiddleware]

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
Expand Down
Loading
Loading