kubiya-control-plane-api 0.1.0__py3-none-any.whl → 0.3.4__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 kubiya-control-plane-api might be problematic. Click here for more details.
- control_plane_api/README.md +266 -0
- control_plane_api/__init__.py +0 -0
- control_plane_api/__version__.py +1 -0
- control_plane_api/alembic/README +1 -0
- control_plane_api/alembic/env.py +98 -0
- control_plane_api/alembic/script.py.mako +28 -0
- control_plane_api/alembic/versions/1382bec74309_initial_migration_with_all_models.py +251 -0
- control_plane_api/alembic/versions/1f54bc2a37e3_add_analytics_tables.py +162 -0
- control_plane_api/alembic/versions/2e4cb136dc10_rename_toolset_ids_to_skill_ids_in_teams.py +30 -0
- control_plane_api/alembic/versions/31cd69a644ce_add_skill_templates_table.py +28 -0
- control_plane_api/alembic/versions/89e127caa47d_add_jobs_and_job_executions_tables.py +161 -0
- control_plane_api/alembic/versions/add_llm_models_table.py +51 -0
- control_plane_api/alembic/versions/b0e10697f212_add_runtime_column_to_teams_simple.py +42 -0
- control_plane_api/alembic/versions/ce43b24b63bf_add_execution_trigger_source_and_fix_.py +155 -0
- control_plane_api/alembic/versions/d4eaf16e3f8d_rename_toolsets_to_skills.py +84 -0
- control_plane_api/alembic/versions/efa2dc427da1_rename_metadata_to_custom_metadata.py +32 -0
- control_plane_api/alembic/versions/f973b431d1ce_add_workflow_executor_to_skill_types.py +44 -0
- control_plane_api/alembic.ini +148 -0
- control_plane_api/api/index.py +12 -0
- control_plane_api/app/__init__.py +11 -0
- control_plane_api/app/activities/__init__.py +20 -0
- control_plane_api/app/activities/agent_activities.py +379 -0
- control_plane_api/app/activities/team_activities.py +410 -0
- control_plane_api/app/activities/temporal_cloud_activities.py +577 -0
- control_plane_api/app/config/__init__.py +35 -0
- control_plane_api/app/config/api_config.py +354 -0
- control_plane_api/app/config/model_pricing.py +318 -0
- control_plane_api/app/config.py +95 -0
- control_plane_api/app/database.py +135 -0
- control_plane_api/app/exceptions.py +408 -0
- control_plane_api/app/lib/__init__.py +11 -0
- control_plane_api/app/lib/job_executor.py +312 -0
- control_plane_api/app/lib/kubiya_client.py +235 -0
- control_plane_api/app/lib/litellm_pricing.py +166 -0
- control_plane_api/app/lib/planning_tools/__init__.py +22 -0
- control_plane_api/app/lib/planning_tools/agents.py +155 -0
- control_plane_api/app/lib/planning_tools/base.py +189 -0
- control_plane_api/app/lib/planning_tools/environments.py +214 -0
- control_plane_api/app/lib/planning_tools/resources.py +240 -0
- control_plane_api/app/lib/planning_tools/teams.py +198 -0
- control_plane_api/app/lib/policy_enforcer_client.py +939 -0
- control_plane_api/app/lib/redis_client.py +436 -0
- control_plane_api/app/lib/supabase.py +71 -0
- control_plane_api/app/lib/temporal_client.py +138 -0
- control_plane_api/app/lib/validation/__init__.py +20 -0
- control_plane_api/app/lib/validation/runtime_validation.py +287 -0
- control_plane_api/app/main.py +128 -0
- control_plane_api/app/middleware/__init__.py +8 -0
- control_plane_api/app/middleware/auth.py +513 -0
- control_plane_api/app/middleware/exception_handler.py +267 -0
- control_plane_api/app/middleware/rate_limiting.py +384 -0
- control_plane_api/app/middleware/request_id.py +202 -0
- control_plane_api/app/models/__init__.py +27 -0
- control_plane_api/app/models/agent.py +79 -0
- control_plane_api/app/models/analytics.py +206 -0
- control_plane_api/app/models/associations.py +81 -0
- control_plane_api/app/models/environment.py +63 -0
- control_plane_api/app/models/execution.py +93 -0
- control_plane_api/app/models/job.py +179 -0
- control_plane_api/app/models/llm_model.py +75 -0
- control_plane_api/app/models/presence.py +49 -0
- control_plane_api/app/models/project.py +47 -0
- control_plane_api/app/models/session.py +38 -0
- control_plane_api/app/models/team.py +66 -0
- control_plane_api/app/models/workflow.py +55 -0
- control_plane_api/app/policies/README.md +121 -0
- control_plane_api/app/policies/approved_users.rego +62 -0
- control_plane_api/app/policies/business_hours.rego +51 -0
- control_plane_api/app/policies/rate_limiting.rego +100 -0
- control_plane_api/app/policies/tool_restrictions.rego +86 -0
- control_plane_api/app/routers/__init__.py +4 -0
- control_plane_api/app/routers/agents.py +364 -0
- control_plane_api/app/routers/agents_v2.py +1260 -0
- control_plane_api/app/routers/analytics.py +1014 -0
- control_plane_api/app/routers/context_manager.py +562 -0
- control_plane_api/app/routers/environment_context.py +270 -0
- control_plane_api/app/routers/environments.py +715 -0
- control_plane_api/app/routers/execution_environment.py +517 -0
- control_plane_api/app/routers/executions.py +1911 -0
- control_plane_api/app/routers/health.py +92 -0
- control_plane_api/app/routers/health_v2.py +326 -0
- control_plane_api/app/routers/integrations.py +274 -0
- control_plane_api/app/routers/jobs.py +1344 -0
- control_plane_api/app/routers/models.py +82 -0
- control_plane_api/app/routers/models_v2.py +361 -0
- control_plane_api/app/routers/policies.py +639 -0
- control_plane_api/app/routers/presence.py +234 -0
- control_plane_api/app/routers/projects.py +902 -0
- control_plane_api/app/routers/runners.py +379 -0
- control_plane_api/app/routers/runtimes.py +172 -0
- control_plane_api/app/routers/secrets.py +155 -0
- control_plane_api/app/routers/skills.py +1001 -0
- control_plane_api/app/routers/skills_definitions.py +140 -0
- control_plane_api/app/routers/task_planning.py +1256 -0
- control_plane_api/app/routers/task_queues.py +654 -0
- control_plane_api/app/routers/team_context.py +270 -0
- control_plane_api/app/routers/teams.py +1400 -0
- control_plane_api/app/routers/worker_queues.py +1545 -0
- control_plane_api/app/routers/workers.py +935 -0
- control_plane_api/app/routers/workflows.py +204 -0
- control_plane_api/app/runtimes/__init__.py +6 -0
- control_plane_api/app/runtimes/validation.py +344 -0
- control_plane_api/app/schemas/job_schemas.py +295 -0
- control_plane_api/app/services/__init__.py +1 -0
- control_plane_api/app/services/agno_service.py +619 -0
- control_plane_api/app/services/litellm_service.py +190 -0
- control_plane_api/app/services/policy_service.py +525 -0
- control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
- control_plane_api/app/skills/__init__.py +44 -0
- control_plane_api/app/skills/base.py +229 -0
- control_plane_api/app/skills/business_intelligence.py +189 -0
- control_plane_api/app/skills/data_visualization.py +154 -0
- control_plane_api/app/skills/docker.py +104 -0
- control_plane_api/app/skills/file_generation.py +94 -0
- control_plane_api/app/skills/file_system.py +110 -0
- control_plane_api/app/skills/python.py +92 -0
- control_plane_api/app/skills/registry.py +65 -0
- control_plane_api/app/skills/shell.py +102 -0
- control_plane_api/app/skills/workflow_executor.py +469 -0
- control_plane_api/app/utils/workflow_executor.py +354 -0
- control_plane_api/app/workflows/__init__.py +11 -0
- control_plane_api/app/workflows/agent_execution.py +507 -0
- control_plane_api/app/workflows/agent_execution_with_skills.py +222 -0
- control_plane_api/app/workflows/namespace_provisioning.py +326 -0
- control_plane_api/app/workflows/team_execution.py +399 -0
- control_plane_api/scripts/seed_models.py +239 -0
- control_plane_api/worker/__init__.py +0 -0
- control_plane_api/worker/activities/__init__.py +0 -0
- control_plane_api/worker/activities/agent_activities.py +1241 -0
- control_plane_api/worker/activities/approval_activities.py +234 -0
- control_plane_api/worker/activities/runtime_activities.py +388 -0
- control_plane_api/worker/activities/skill_activities.py +267 -0
- control_plane_api/worker/activities/team_activities.py +1217 -0
- control_plane_api/worker/config/__init__.py +31 -0
- control_plane_api/worker/config/worker_config.py +275 -0
- control_plane_api/worker/control_plane_client.py +529 -0
- control_plane_api/worker/examples/analytics_integration_example.py +362 -0
- control_plane_api/worker/models/__init__.py +1 -0
- control_plane_api/worker/models/inputs.py +89 -0
- control_plane_api/worker/runtimes/__init__.py +31 -0
- control_plane_api/worker/runtimes/base.py +789 -0
- control_plane_api/worker/runtimes/claude_code_runtime.py +1443 -0
- control_plane_api/worker/runtimes/default_runtime.py +617 -0
- control_plane_api/worker/runtimes/factory.py +173 -0
- control_plane_api/worker/runtimes/validation.py +93 -0
- control_plane_api/worker/services/__init__.py +1 -0
- control_plane_api/worker/services/agent_executor.py +422 -0
- control_plane_api/worker/services/agent_executor_v2.py +383 -0
- control_plane_api/worker/services/analytics_collector.py +457 -0
- control_plane_api/worker/services/analytics_service.py +464 -0
- control_plane_api/worker/services/approval_tools.py +310 -0
- control_plane_api/worker/services/approval_tools_agno.py +207 -0
- control_plane_api/worker/services/cancellation_manager.py +177 -0
- control_plane_api/worker/services/data_visualization.py +827 -0
- control_plane_api/worker/services/jira_tools.py +257 -0
- control_plane_api/worker/services/runtime_analytics.py +328 -0
- control_plane_api/worker/services/session_service.py +194 -0
- control_plane_api/worker/services/skill_factory.py +175 -0
- control_plane_api/worker/services/team_executor.py +574 -0
- control_plane_api/worker/services/team_executor_v2.py +465 -0
- control_plane_api/worker/services/workflow_executor_tools.py +1418 -0
- control_plane_api/worker/tests/__init__.py +1 -0
- control_plane_api/worker/tests/e2e/__init__.py +0 -0
- control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
- control_plane_api/worker/tests/integration/__init__.py +0 -0
- control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
- control_plane_api/worker/tests/unit/__init__.py +0 -0
- control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
- control_plane_api/worker/utils/__init__.py +1 -0
- control_plane_api/worker/utils/chunk_batcher.py +305 -0
- control_plane_api/worker/utils/retry_utils.py +60 -0
- control_plane_api/worker/utils/streaming_utils.py +373 -0
- control_plane_api/worker/worker.py +753 -0
- control_plane_api/worker/workflows/__init__.py +0 -0
- control_plane_api/worker/workflows/agent_execution.py +589 -0
- control_plane_api/worker/workflows/team_execution.py +429 -0
- kubiya_control_plane_api-0.3.4.dist-info/METADATA +229 -0
- kubiya_control_plane_api-0.3.4.dist-info/RECORD +182 -0
- kubiya_control_plane_api-0.3.4.dist-info/entry_points.txt +2 -0
- kubiya_control_plane_api-0.3.4.dist-info/top_level.txt +1 -0
- kubiya_control_plane_api-0.1.0.dist-info/METADATA +0 -66
- kubiya_control_plane_api-0.1.0.dist-info/RECORD +0 -5
- kubiya_control_plane_api-0.1.0.dist-info/top_level.txt +0 -1
- {kubiya_control_plane_api-0.1.0.dist-info/licenses → control_plane_api}/LICENSE +0 -0
- {kubiya_control_plane_api-0.1.0.dist-info → kubiya_control_plane_api-0.3.4.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings
|
|
2
|
+
from pydantic import model_validator
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings(BaseSettings):
|
|
8
|
+
"""Application settings loaded from environment variables"""
|
|
9
|
+
|
|
10
|
+
# API Settings
|
|
11
|
+
API_HOST: str = "0.0.0.0"
|
|
12
|
+
API_PORT: int = 7777
|
|
13
|
+
API_TITLE: str = "Agent Control Plane"
|
|
14
|
+
API_VERSION: str = "0.1.0"
|
|
15
|
+
API_DESCRIPTION: str = "Multi-tenant agent orchestration with Temporal workflows"
|
|
16
|
+
|
|
17
|
+
# Supabase Settings (replaces DATABASE_URL for serverless)
|
|
18
|
+
SUPABASE_URL: str = "" # Required: Set via environment variable
|
|
19
|
+
SUPABASE_ANON_KEY: Optional[str] = None
|
|
20
|
+
SUPABASE_SERVICE_KEY: str = "" # Required: Set via environment variable for admin operations
|
|
21
|
+
|
|
22
|
+
# Legacy Database URL (kept for backward compatibility)
|
|
23
|
+
DATABASE_URL: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
@model_validator(mode='after')
|
|
26
|
+
def set_database_url_fallback(self):
|
|
27
|
+
"""Set DATABASE_URL from SUPABASE_POSTGRES_URL if not already set (for Vercel compatibility)"""
|
|
28
|
+
if not self.DATABASE_URL:
|
|
29
|
+
# Check for Vercel's SUPABASE_POSTGRES_URL or other variants
|
|
30
|
+
supabase_db_url = (
|
|
31
|
+
os.environ.get("SUPABASE_POSTGRES_URL") or
|
|
32
|
+
os.environ.get("SUPABASE_POSTGRES_PRISMA_URL") or
|
|
33
|
+
os.environ.get("SUPABASE_DB_URL")
|
|
34
|
+
)
|
|
35
|
+
if supabase_db_url:
|
|
36
|
+
# Fix URL format for SQLAlchemy 2.0+ (postgres:// -> postgresql://)
|
|
37
|
+
if supabase_db_url.startswith("postgres://"):
|
|
38
|
+
supabase_db_url = supabase_db_url.replace("postgres://", "postgresql://", 1)
|
|
39
|
+
# Remove invalid Supabase pooler parameters that SQLAlchemy doesn't understand
|
|
40
|
+
supabase_db_url = supabase_db_url.replace("&supa=base-pooler.x", "")
|
|
41
|
+
self.DATABASE_URL = supabase_db_url
|
|
42
|
+
|
|
43
|
+
# Also fix DATABASE_URL if it was already set with old postgres:// scheme
|
|
44
|
+
if self.DATABASE_URL and self.DATABASE_URL.startswith("postgres://"):
|
|
45
|
+
self.DATABASE_URL = self.DATABASE_URL.replace("postgres://", "postgresql://", 1)
|
|
46
|
+
|
|
47
|
+
# Remove invalid Supabase pooler parameters from DATABASE_URL
|
|
48
|
+
if self.DATABASE_URL:
|
|
49
|
+
self.DATABASE_URL = self.DATABASE_URL.replace("&supa=base-pooler.x", "")
|
|
50
|
+
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
# Redis Settings (from Composer)
|
|
54
|
+
REDIS_URL: str = "redis://localhost:6379/0"
|
|
55
|
+
REDIS_HOST: Optional[str] = None
|
|
56
|
+
REDIS_PORT: int = 6379
|
|
57
|
+
REDIS_PASSWORD: Optional[str] = None
|
|
58
|
+
REDIS_DB: int = 0
|
|
59
|
+
|
|
60
|
+
# Temporal Settings
|
|
61
|
+
TEMPORAL_HOST: str = "localhost:7233"
|
|
62
|
+
TEMPORAL_NAMESPACE: str = "default"
|
|
63
|
+
TEMPORAL_CLIENT_CERT_PATH: Optional[str] = None
|
|
64
|
+
TEMPORAL_CLIENT_KEY_PATH: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
# Security Settings
|
|
67
|
+
SECRET_KEY: str = "change-this-secret-key-in-production"
|
|
68
|
+
ALGORITHM: str = "HS256"
|
|
69
|
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
70
|
+
|
|
71
|
+
# Logging
|
|
72
|
+
LOG_LEVEL: str = "info"
|
|
73
|
+
|
|
74
|
+
# CORS
|
|
75
|
+
CORS_ORIGINS: list[str] = ["*"]
|
|
76
|
+
|
|
77
|
+
# LiteLLM Settings
|
|
78
|
+
LITELLM_API_BASE: str = "https://llm-proxy.kubiya.ai"
|
|
79
|
+
LITELLM_API_KEY: str = "" # Required: Set via environment variable
|
|
80
|
+
LITELLM_DEFAULT_MODEL: str = "kubiya/claude-sonnet-4"
|
|
81
|
+
LITELLM_TIMEOUT: int = 300
|
|
82
|
+
|
|
83
|
+
# Kubiya API Settings
|
|
84
|
+
KUBIYA_API_BASE: str = "https://api.kubiya.ai"
|
|
85
|
+
|
|
86
|
+
# Environment
|
|
87
|
+
ENVIRONMENT: str = "development" # development, staging, production
|
|
88
|
+
|
|
89
|
+
class Config:
|
|
90
|
+
env_file = ".env"
|
|
91
|
+
case_sensitive = True
|
|
92
|
+
extra = "ignore" # Allow extra environment variables (for worker-specific vars)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
settings = Settings()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from sqlalchemy import create_engine, event, text
|
|
2
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
3
|
+
from sqlalchemy.orm import sessionmaker
|
|
4
|
+
from sqlalchemy.pool import NullPool
|
|
5
|
+
from control_plane_api.app.config import settings
|
|
6
|
+
import structlog
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
logger = structlog.get_logger()
|
|
10
|
+
|
|
11
|
+
# Lazy-load engine and session to avoid crashing on import if DATABASE_URL not set
|
|
12
|
+
_engine = None
|
|
13
|
+
_SessionLocal = None
|
|
14
|
+
|
|
15
|
+
# Create base class for models
|
|
16
|
+
Base = declarative_base()
|
|
17
|
+
|
|
18
|
+
# Detect if running in serverless environment
|
|
19
|
+
# Only enable serverless mode in actual Vercel/Lambda, not in local development
|
|
20
|
+
IS_SERVERLESS = bool(os.getenv("VERCEL") or os.getenv("AWS_LAMBDA_FUNCTION_NAME"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_engine():
|
|
24
|
+
"""Get or create the database engine optimized for serverless"""
|
|
25
|
+
global _engine
|
|
26
|
+
if _engine is None:
|
|
27
|
+
if not settings.database_url:
|
|
28
|
+
raise RuntimeError(
|
|
29
|
+
"DATABASE_URL not configured. SQLAlchemy-based endpoints are not available. "
|
|
30
|
+
"Please use Supabase-based endpoints instead."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Serverless-optimized configuration
|
|
34
|
+
if IS_SERVERLESS:
|
|
35
|
+
logger.info("creating_serverless_database_engine")
|
|
36
|
+
_engine = create_engine(
|
|
37
|
+
settings.database_url,
|
|
38
|
+
# Use NullPool for serverless - no connection pooling, create fresh connections
|
|
39
|
+
poolclass=NullPool,
|
|
40
|
+
# Verify connections are alive before using them
|
|
41
|
+
pool_pre_ping=True,
|
|
42
|
+
# Short connection timeout for serverless
|
|
43
|
+
connect_args={
|
|
44
|
+
"connect_timeout": 5,
|
|
45
|
+
"options": "-c statement_timeout=30000", # 30s query timeout
|
|
46
|
+
"keepalives": 1,
|
|
47
|
+
"keepalives_idle": 30,
|
|
48
|
+
"keepalives_interval": 10,
|
|
49
|
+
"keepalives_count": 5,
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
else:
|
|
53
|
+
# Traditional server configuration with connection pooling
|
|
54
|
+
logger.info("creating_traditional_database_engine")
|
|
55
|
+
_engine = create_engine(
|
|
56
|
+
settings.database_url,
|
|
57
|
+
pool_pre_ping=True,
|
|
58
|
+
pool_size=2, # Reduced for better resource management
|
|
59
|
+
max_overflow=3, # Reduced overflow
|
|
60
|
+
pool_recycle=300, # Recycle connections after 5 minutes
|
|
61
|
+
pool_timeout=10, # Reduced timeout
|
|
62
|
+
connect_args={
|
|
63
|
+
"connect_timeout": 10,
|
|
64
|
+
"options": "-c statement_timeout=30000"
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Add connection event listeners for better debugging
|
|
69
|
+
@event.listens_for(_engine, "connect")
|
|
70
|
+
def receive_connect(dbapi_conn, connection_record):
|
|
71
|
+
logger.debug("database_connection_established")
|
|
72
|
+
|
|
73
|
+
@event.listens_for(_engine, "close")
|
|
74
|
+
def receive_close(dbapi_conn, connection_record):
|
|
75
|
+
logger.debug("database_connection_closed")
|
|
76
|
+
|
|
77
|
+
logger.info("database_engine_created", serverless=IS_SERVERLESS)
|
|
78
|
+
return _engine
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_session_local():
|
|
82
|
+
"""Get or create the session factory"""
|
|
83
|
+
global _SessionLocal
|
|
84
|
+
if _SessionLocal is None:
|
|
85
|
+
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=get_engine())
|
|
86
|
+
return _SessionLocal
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_db():
|
|
90
|
+
"""Dependency for getting database sessions"""
|
|
91
|
+
SessionLocal = get_session_local()
|
|
92
|
+
db = SessionLocal()
|
|
93
|
+
try:
|
|
94
|
+
yield db
|
|
95
|
+
except Exception:
|
|
96
|
+
# Rollback on any error during request processing
|
|
97
|
+
db.rollback()
|
|
98
|
+
raise
|
|
99
|
+
finally:
|
|
100
|
+
db.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def dispose_engine():
|
|
104
|
+
"""
|
|
105
|
+
Dispose of the database engine to release all connections.
|
|
106
|
+
Should be called at the end of serverless function invocations.
|
|
107
|
+
"""
|
|
108
|
+
global _engine
|
|
109
|
+
if _engine is not None:
|
|
110
|
+
logger.info("disposing_database_engine")
|
|
111
|
+
_engine.dispose()
|
|
112
|
+
_engine = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def init_db():
|
|
116
|
+
"""Initialize database tables"""
|
|
117
|
+
Base.metadata.create_all(bind=get_engine())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def health_check_db():
|
|
121
|
+
"""
|
|
122
|
+
Health check for database connectivity.
|
|
123
|
+
Returns True if database is accessible, False otherwise.
|
|
124
|
+
"""
|
|
125
|
+
from sqlalchemy.exc import OperationalError, DisconnectionError
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
engine = get_engine()
|
|
129
|
+
with engine.connect() as conn:
|
|
130
|
+
conn.execute(text("SELECT 1"))
|
|
131
|
+
logger.info("database_health_check_passed")
|
|
132
|
+
return True
|
|
133
|
+
except (OperationalError, DisconnectionError) as e:
|
|
134
|
+
logger.error("database_health_check_failed", error=str(e))
|
|
135
|
+
return False
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized exception hierarchy for Control Plane API.
|
|
3
|
+
|
|
4
|
+
Provides standardized exceptions with consistent error codes and status codes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional, Dict, Any
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ErrorCode(str, Enum):
|
|
12
|
+
"""Standard error codes for API responses."""
|
|
13
|
+
|
|
14
|
+
# Validation errors (400)
|
|
15
|
+
VALIDATION_ERROR = "VALIDATION_ERROR"
|
|
16
|
+
INVALID_INPUT = "INVALID_INPUT"
|
|
17
|
+
MISSING_PARAMETER = "MISSING_PARAMETER"
|
|
18
|
+
INVALID_FORMAT = "INVALID_FORMAT"
|
|
19
|
+
|
|
20
|
+
# Authentication/Authorization (401/403)
|
|
21
|
+
AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
|
|
22
|
+
INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
|
|
23
|
+
TOKEN_EXPIRED = "TOKEN_EXPIRED"
|
|
24
|
+
AUTHORIZATION_ERROR = "AUTHORIZATION_ERROR"
|
|
25
|
+
INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS"
|
|
26
|
+
|
|
27
|
+
# Resource errors (404/409)
|
|
28
|
+
RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
|
|
29
|
+
RESOURCE_ALREADY_EXISTS = "RESOURCE_ALREADY_EXISTS"
|
|
30
|
+
RESOURCE_CONFLICT = "RESOURCE_CONFLICT"
|
|
31
|
+
|
|
32
|
+
# Rate limiting (429)
|
|
33
|
+
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
|
|
34
|
+
|
|
35
|
+
# External service errors (502/503)
|
|
36
|
+
EXTERNAL_SERVICE_ERROR = "EXTERNAL_SERVICE_ERROR"
|
|
37
|
+
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
|
|
38
|
+
TIMEOUT_ERROR = "TIMEOUT_ERROR"
|
|
39
|
+
|
|
40
|
+
# Internal errors (500)
|
|
41
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
42
|
+
DATABASE_ERROR = "DATABASE_ERROR"
|
|
43
|
+
WORKFLOW_ERROR = "WORKFLOW_ERROR"
|
|
44
|
+
CONFIGURATION_ERROR = "CONFIGURATION_ERROR"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ControlPlaneException(Exception):
|
|
48
|
+
"""
|
|
49
|
+
Base exception for all Control Plane errors.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
message: Human-readable error message
|
|
53
|
+
error_code: Machine-readable error code
|
|
54
|
+
status_code: HTTP status code
|
|
55
|
+
details: Additional error details
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
message: str,
|
|
61
|
+
error_code: Optional[ErrorCode] = None,
|
|
62
|
+
status_code: int = 500,
|
|
63
|
+
details: Optional[Dict[str, Any]] = None,
|
|
64
|
+
):
|
|
65
|
+
super().__init__(message)
|
|
66
|
+
self.message = message
|
|
67
|
+
self.error_code = error_code or ErrorCode.INTERNAL_ERROR
|
|
68
|
+
self.status_code = status_code
|
|
69
|
+
self.details = details or {}
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
72
|
+
"""Convert exception to dictionary for API response."""
|
|
73
|
+
return {
|
|
74
|
+
"code": self.error_code,
|
|
75
|
+
"message": self.message,
|
|
76
|
+
"details": self.details,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# 400 - Bad Request Errors
|
|
81
|
+
|
|
82
|
+
class ValidationError(ControlPlaneException):
|
|
83
|
+
"""Raised when input validation fails."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
message: str,
|
|
88
|
+
field: Optional[str] = None,
|
|
89
|
+
value: Optional[Any] = None,
|
|
90
|
+
details: Optional[Dict[str, Any]] = None,
|
|
91
|
+
):
|
|
92
|
+
if field:
|
|
93
|
+
details = details or {}
|
|
94
|
+
details["field"] = field
|
|
95
|
+
if value is not None:
|
|
96
|
+
details["value"] = str(value)
|
|
97
|
+
|
|
98
|
+
super().__init__(
|
|
99
|
+
message=message,
|
|
100
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
101
|
+
status_code=400,
|
|
102
|
+
details=details,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class InvalidInputError(ControlPlaneException):
|
|
107
|
+
"""Raised when input is invalid but passes basic validation."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
|
110
|
+
super().__init__(
|
|
111
|
+
message=message,
|
|
112
|
+
error_code=ErrorCode.INVALID_INPUT,
|
|
113
|
+
status_code=400,
|
|
114
|
+
details=details,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# 401 - Authentication Errors
|
|
119
|
+
|
|
120
|
+
class AuthenticationError(ControlPlaneException):
|
|
121
|
+
"""Raised when authentication fails."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, message: str = "Authentication required"):
|
|
124
|
+
super().__init__(
|
|
125
|
+
message=message,
|
|
126
|
+
error_code=ErrorCode.AUTHENTICATION_ERROR,
|
|
127
|
+
status_code=401,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class InvalidCredentialsError(ControlPlaneException):
|
|
132
|
+
"""Raised when credentials are invalid."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, message: str = "Invalid credentials"):
|
|
135
|
+
super().__init__(
|
|
136
|
+
message=message,
|
|
137
|
+
error_code=ErrorCode.INVALID_CREDENTIALS,
|
|
138
|
+
status_code=401,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TokenExpiredError(ControlPlaneException):
|
|
143
|
+
"""Raised when authentication token has expired."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, message: str = "Token has expired"):
|
|
146
|
+
super().__init__(
|
|
147
|
+
message=message,
|
|
148
|
+
error_code=ErrorCode.TOKEN_EXPIRED,
|
|
149
|
+
status_code=401,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# 403 - Authorization Errors
|
|
154
|
+
|
|
155
|
+
class AuthorizationError(ControlPlaneException):
|
|
156
|
+
"""Raised when user lacks required permissions."""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
message: str = "Insufficient permissions",
|
|
161
|
+
required_permission: Optional[str] = None,
|
|
162
|
+
):
|
|
163
|
+
details = {}
|
|
164
|
+
if required_permission:
|
|
165
|
+
details["required_permission"] = required_permission
|
|
166
|
+
|
|
167
|
+
super().__init__(
|
|
168
|
+
message=message,
|
|
169
|
+
error_code=ErrorCode.AUTHORIZATION_ERROR,
|
|
170
|
+
status_code=403,
|
|
171
|
+
details=details,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# 404 - Not Found Errors
|
|
176
|
+
|
|
177
|
+
class ResourceNotFoundError(ControlPlaneException):
|
|
178
|
+
"""Raised when a requested resource doesn't exist."""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
resource_type: str,
|
|
183
|
+
resource_id: str,
|
|
184
|
+
message: Optional[str] = None,
|
|
185
|
+
):
|
|
186
|
+
if not message:
|
|
187
|
+
message = f"{resource_type} with ID '{resource_id}' not found"
|
|
188
|
+
|
|
189
|
+
super().__init__(
|
|
190
|
+
message=message,
|
|
191
|
+
error_code=ErrorCode.RESOURCE_NOT_FOUND,
|
|
192
|
+
status_code=404,
|
|
193
|
+
details={
|
|
194
|
+
"resource_type": resource_type,
|
|
195
|
+
"resource_id": resource_id,
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# 409 - Conflict Errors
|
|
201
|
+
|
|
202
|
+
class ResourceAlreadyExistsError(ControlPlaneException):
|
|
203
|
+
"""Raised when attempting to create a resource that already exists."""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
resource_type: str,
|
|
208
|
+
identifier: str,
|
|
209
|
+
message: Optional[str] = None,
|
|
210
|
+
):
|
|
211
|
+
if not message:
|
|
212
|
+
message = f"{resource_type} already exists: {identifier}"
|
|
213
|
+
|
|
214
|
+
super().__init__(
|
|
215
|
+
message=message,
|
|
216
|
+
error_code=ErrorCode.RESOURCE_ALREADY_EXISTS,
|
|
217
|
+
status_code=409,
|
|
218
|
+
details={
|
|
219
|
+
"resource_type": resource_type,
|
|
220
|
+
"identifier": identifier,
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class ResourceConflictError(ControlPlaneException):
|
|
226
|
+
"""Raised when a resource operation conflicts with current state."""
|
|
227
|
+
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
message: str,
|
|
231
|
+
resource_type: Optional[str] = None,
|
|
232
|
+
resource_id: Optional[str] = None,
|
|
233
|
+
):
|
|
234
|
+
details = {}
|
|
235
|
+
if resource_type:
|
|
236
|
+
details["resource_type"] = resource_type
|
|
237
|
+
if resource_id:
|
|
238
|
+
details["resource_id"] = resource_id
|
|
239
|
+
|
|
240
|
+
super().__init__(
|
|
241
|
+
message=message,
|
|
242
|
+
error_code=ErrorCode.RESOURCE_CONFLICT,
|
|
243
|
+
status_code=409,
|
|
244
|
+
details=details,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# 429 - Rate Limit Errors
|
|
249
|
+
|
|
250
|
+
class RateLimitError(ControlPlaneException):
|
|
251
|
+
"""Raised when rate limit is exceeded."""
|
|
252
|
+
|
|
253
|
+
def __init__(
|
|
254
|
+
self,
|
|
255
|
+
limit: int,
|
|
256
|
+
window: str,
|
|
257
|
+
retry_after: Optional[int] = None,
|
|
258
|
+
):
|
|
259
|
+
details = {
|
|
260
|
+
"limit": limit,
|
|
261
|
+
"window": window,
|
|
262
|
+
}
|
|
263
|
+
if retry_after:
|
|
264
|
+
details["retry_after_seconds"] = retry_after
|
|
265
|
+
|
|
266
|
+
super().__init__(
|
|
267
|
+
message=f"Rate limit exceeded: {limit} requests per {window}",
|
|
268
|
+
error_code=ErrorCode.RATE_LIMIT_EXCEEDED,
|
|
269
|
+
status_code=429,
|
|
270
|
+
details=details,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# 502/503 - Service Errors
|
|
275
|
+
|
|
276
|
+
class ExternalServiceError(ControlPlaneException):
|
|
277
|
+
"""Raised when an external service call fails."""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
service: str,
|
|
282
|
+
message: str,
|
|
283
|
+
status_code: int = 502,
|
|
284
|
+
details: Optional[Dict[str, Any]] = None,
|
|
285
|
+
):
|
|
286
|
+
full_details = {"service": service}
|
|
287
|
+
if details:
|
|
288
|
+
full_details.update(details)
|
|
289
|
+
|
|
290
|
+
super().__init__(
|
|
291
|
+
message=f"{service}: {message}",
|
|
292
|
+
error_code=ErrorCode.EXTERNAL_SERVICE_ERROR,
|
|
293
|
+
status_code=status_code,
|
|
294
|
+
details=full_details,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class ServiceUnavailableError(ControlPlaneException):
|
|
299
|
+
"""Raised when a service is temporarily unavailable."""
|
|
300
|
+
|
|
301
|
+
def __init__(
|
|
302
|
+
self,
|
|
303
|
+
service: str,
|
|
304
|
+
message: Optional[str] = None,
|
|
305
|
+
retry_after: Optional[int] = None,
|
|
306
|
+
):
|
|
307
|
+
if not message:
|
|
308
|
+
message = f"{service} is temporarily unavailable"
|
|
309
|
+
|
|
310
|
+
details = {"service": service}
|
|
311
|
+
if retry_after:
|
|
312
|
+
details["retry_after_seconds"] = retry_after
|
|
313
|
+
|
|
314
|
+
super().__init__(
|
|
315
|
+
message=message,
|
|
316
|
+
error_code=ErrorCode.SERVICE_UNAVAILABLE,
|
|
317
|
+
status_code=503,
|
|
318
|
+
details=details,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class TimeoutError(ControlPlaneException):
|
|
323
|
+
"""Raised when an operation times out."""
|
|
324
|
+
|
|
325
|
+
def __init__(
|
|
326
|
+
self,
|
|
327
|
+
operation: str,
|
|
328
|
+
timeout_seconds: Optional[float] = None,
|
|
329
|
+
):
|
|
330
|
+
details = {"operation": operation}
|
|
331
|
+
if timeout_seconds:
|
|
332
|
+
details["timeout_seconds"] = timeout_seconds
|
|
333
|
+
|
|
334
|
+
super().__init__(
|
|
335
|
+
message=f"Operation '{operation}' timed out",
|
|
336
|
+
error_code=ErrorCode.TIMEOUT_ERROR,
|
|
337
|
+
status_code=504,
|
|
338
|
+
details=details,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# 500 - Internal Errors
|
|
343
|
+
|
|
344
|
+
class DatabaseError(ControlPlaneException):
|
|
345
|
+
"""Raised when a database operation fails."""
|
|
346
|
+
|
|
347
|
+
def __init__(
|
|
348
|
+
self,
|
|
349
|
+
message: str,
|
|
350
|
+
operation: Optional[str] = None,
|
|
351
|
+
details: Optional[Dict[str, Any]] = None,
|
|
352
|
+
):
|
|
353
|
+
full_details = details or {}
|
|
354
|
+
if operation:
|
|
355
|
+
full_details["operation"] = operation
|
|
356
|
+
|
|
357
|
+
super().__init__(
|
|
358
|
+
message=message,
|
|
359
|
+
error_code=ErrorCode.DATABASE_ERROR,
|
|
360
|
+
status_code=500,
|
|
361
|
+
details=full_details,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class WorkflowExecutionError(ControlPlaneException):
|
|
366
|
+
"""Raised when a Temporal workflow execution fails."""
|
|
367
|
+
|
|
368
|
+
def __init__(
|
|
369
|
+
self,
|
|
370
|
+
workflow_id: str,
|
|
371
|
+
message: str,
|
|
372
|
+
workflow_type: Optional[str] = None,
|
|
373
|
+
details: Optional[Dict[str, Any]] = None,
|
|
374
|
+
):
|
|
375
|
+
full_details = {
|
|
376
|
+
"workflow_id": workflow_id,
|
|
377
|
+
}
|
|
378
|
+
if workflow_type:
|
|
379
|
+
full_details["workflow_type"] = workflow_type
|
|
380
|
+
if details:
|
|
381
|
+
full_details.update(details)
|
|
382
|
+
|
|
383
|
+
super().__init__(
|
|
384
|
+
message=f"Workflow {workflow_id}: {message}",
|
|
385
|
+
error_code=ErrorCode.WORKFLOW_ERROR,
|
|
386
|
+
status_code=500,
|
|
387
|
+
details=full_details,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class ConfigurationError(ControlPlaneException):
|
|
392
|
+
"""Raised when configuration is invalid or missing."""
|
|
393
|
+
|
|
394
|
+
def __init__(
|
|
395
|
+
self,
|
|
396
|
+
message: str,
|
|
397
|
+
config_key: Optional[str] = None,
|
|
398
|
+
):
|
|
399
|
+
details = {}
|
|
400
|
+
if config_key:
|
|
401
|
+
details["config_key"] = config_key
|
|
402
|
+
|
|
403
|
+
super().__init__(
|
|
404
|
+
message=message,
|
|
405
|
+
error_code=ErrorCode.CONFIGURATION_ERROR,
|
|
406
|
+
status_code=500,
|
|
407
|
+
details=details,
|
|
408
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Shared library modules"""
|
|
2
|
+
|
|
3
|
+
from control_plane_api.app.lib.supabase import get_supabase, execute_with_org_context
|
|
4
|
+
from control_plane_api.app.lib.temporal_client import get_temporal_client, close_temporal_client
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"get_supabase",
|
|
8
|
+
"execute_with_org_context",
|
|
9
|
+
"get_temporal_client",
|
|
10
|
+
"close_temporal_client",
|
|
11
|
+
]
|