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,715 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Environments router - Clean API for environment management.
|
|
3
|
+
|
|
4
|
+
This router provides /environments endpoints that map to the environments table.
|
|
5
|
+
The naming "environments" is internal - externally we call them "environments".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
import structlog
|
|
13
|
+
import uuid
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from control_plane_api.app.middleware.auth import get_current_organization
|
|
17
|
+
from control_plane_api.app.lib.supabase import get_supabase
|
|
18
|
+
from control_plane_api.app.lib.temporal_client import get_temporal_client
|
|
19
|
+
|
|
20
|
+
logger = structlog.get_logger()
|
|
21
|
+
|
|
22
|
+
router = APIRouter()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Execution Environment Model (shared with agents/teams)
|
|
26
|
+
class ExecutionEnvironment(BaseModel):
|
|
27
|
+
"""Execution environment configuration - env vars, secrets, and integration credentials"""
|
|
28
|
+
env_vars: dict[str, str] = Field(default_factory=dict, description="Environment variables (key-value pairs)")
|
|
29
|
+
secrets: list[str] = Field(default_factory=list, description="Secret names from Kubiya vault")
|
|
30
|
+
integration_ids: list[str] = Field(default_factory=list, description="Integration UUIDs for delegated credentials")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Pydantic schemas
|
|
34
|
+
class EnvironmentCreate(BaseModel):
|
|
35
|
+
name: str = Field(..., description="Environment name (e.g., default, production)", min_length=2, max_length=100)
|
|
36
|
+
display_name: str | None = Field(None, description="User-friendly display name")
|
|
37
|
+
description: str | None = Field(None, description="Environment description")
|
|
38
|
+
tags: List[str] = Field(default_factory=list, description="Tags for categorization")
|
|
39
|
+
settings: dict = Field(default_factory=dict, description="Environment settings")
|
|
40
|
+
execution_environment: ExecutionEnvironment | None = Field(None, description="Execution environment configuration")
|
|
41
|
+
# Note: priority and policy_ids not supported by environments table
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EnvironmentUpdate(BaseModel):
|
|
45
|
+
name: str | None = None
|
|
46
|
+
display_name: str | None = None
|
|
47
|
+
description: str | None = None
|
|
48
|
+
tags: List[str] | None = None
|
|
49
|
+
settings: dict | None = None
|
|
50
|
+
status: str | None = None
|
|
51
|
+
execution_environment: ExecutionEnvironment | None = None
|
|
52
|
+
# Note: priority and policy_ids not supported by environments table
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EnvironmentResponse(BaseModel):
|
|
56
|
+
id: str
|
|
57
|
+
organization_id: str
|
|
58
|
+
name: str
|
|
59
|
+
display_name: str | None
|
|
60
|
+
description: str | None
|
|
61
|
+
tags: List[str]
|
|
62
|
+
settings: dict
|
|
63
|
+
status: str
|
|
64
|
+
created_at: str
|
|
65
|
+
updated_at: str
|
|
66
|
+
created_by: str | None
|
|
67
|
+
|
|
68
|
+
# Temporal Cloud provisioning fields
|
|
69
|
+
worker_token: str | None = None
|
|
70
|
+
provisioning_workflow_id: str | None = None
|
|
71
|
+
provisioned_at: str | None = None
|
|
72
|
+
error_message: str | None = None
|
|
73
|
+
temporal_namespace_id: str | None = None
|
|
74
|
+
|
|
75
|
+
# Worker metrics (deprecated at environment level, use worker_queues)
|
|
76
|
+
active_workers: int = 0
|
|
77
|
+
idle_workers: int = 0
|
|
78
|
+
busy_workers: int = 0
|
|
79
|
+
|
|
80
|
+
# Skills (populated from associations)
|
|
81
|
+
skill_ids: List[str] = []
|
|
82
|
+
skills: List[dict] = []
|
|
83
|
+
|
|
84
|
+
# Execution environment configuration
|
|
85
|
+
execution_environment: dict = {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class WorkerCommandResponse(BaseModel):
|
|
89
|
+
"""Response with worker registration command"""
|
|
90
|
+
worker_token: str
|
|
91
|
+
environment_name: str
|
|
92
|
+
command: str
|
|
93
|
+
command_parts: dict
|
|
94
|
+
namespace_status: str
|
|
95
|
+
can_register: bool
|
|
96
|
+
provisioning_workflow_id: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def ensure_default_environment(organization: dict) -> Optional[dict]:
|
|
100
|
+
"""
|
|
101
|
+
Ensure the organization has a default environment.
|
|
102
|
+
Creates one if it doesn't exist.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
client = get_supabase()
|
|
106
|
+
|
|
107
|
+
# Check if default environment exists
|
|
108
|
+
existing = (
|
|
109
|
+
client.table("environments")
|
|
110
|
+
.select("*")
|
|
111
|
+
.eq("organization_id", organization["id"])
|
|
112
|
+
.eq("name", "default")
|
|
113
|
+
.execute()
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if existing.data:
|
|
117
|
+
return existing.data[0]
|
|
118
|
+
|
|
119
|
+
# Create default environment
|
|
120
|
+
env_id = str(uuid.uuid4())
|
|
121
|
+
now = datetime.utcnow().isoformat()
|
|
122
|
+
|
|
123
|
+
default_env = {
|
|
124
|
+
"id": env_id,
|
|
125
|
+
"organization_id": organization["id"],
|
|
126
|
+
"name": "default",
|
|
127
|
+
"display_name": "Default Environment",
|
|
128
|
+
"description": "Default environment for all workers",
|
|
129
|
+
"tags": [],
|
|
130
|
+
"settings": {},
|
|
131
|
+
"status": "active",
|
|
132
|
+
"created_at": now,
|
|
133
|
+
"updated_at": now,
|
|
134
|
+
"created_by": organization.get("user_id"),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
result = client.table("environments").insert(default_env).execute()
|
|
138
|
+
|
|
139
|
+
if result.data:
|
|
140
|
+
logger.info(
|
|
141
|
+
"default_environment_created",
|
|
142
|
+
environment_id=env_id,
|
|
143
|
+
org_id=organization["id"],
|
|
144
|
+
)
|
|
145
|
+
return result.data[0]
|
|
146
|
+
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.error("ensure_default_environment_failed", error=str(e), org_id=organization.get("id"))
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_environment_skills(client, organization_id: str, environment_id: str) -> tuple[List[str], List[dict]]:
|
|
155
|
+
"""Get skills associated with an environment"""
|
|
156
|
+
try:
|
|
157
|
+
# Get associations with full skill data
|
|
158
|
+
result = (
|
|
159
|
+
client.table("skill_associations")
|
|
160
|
+
.select("skill_id, configuration_override, skills(*)")
|
|
161
|
+
.eq("organization_id", organization_id)
|
|
162
|
+
.eq("entity_type", "environment")
|
|
163
|
+
.eq("entity_id", environment_id)
|
|
164
|
+
.execute()
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
skill_ids = []
|
|
168
|
+
skills = []
|
|
169
|
+
|
|
170
|
+
for item in result.data:
|
|
171
|
+
skill_data = item.get("skills")
|
|
172
|
+
if skill_data:
|
|
173
|
+
skill_ids.append(skill_data["id"])
|
|
174
|
+
|
|
175
|
+
# Merge configuration with override
|
|
176
|
+
config = skill_data.get("configuration", {})
|
|
177
|
+
override = item.get("configuration_override")
|
|
178
|
+
if override:
|
|
179
|
+
config = {**config, **override}
|
|
180
|
+
|
|
181
|
+
skills.append({
|
|
182
|
+
"id": skill_data["id"],
|
|
183
|
+
"name": skill_data["name"],
|
|
184
|
+
"type": skill_data["skill_type"],
|
|
185
|
+
"description": skill_data.get("description"),
|
|
186
|
+
"enabled": skill_data.get("enabled", True),
|
|
187
|
+
"configuration": config,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
return skill_ids, skills
|
|
191
|
+
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.error("get_environment_skills_failed", error=str(e), environment_id=environment_id)
|
|
194
|
+
return [], []
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@router.post("", response_model=EnvironmentResponse, status_code=status.HTTP_201_CREATED)
|
|
198
|
+
async def create_environment(
|
|
199
|
+
env_data: EnvironmentCreate,
|
|
200
|
+
request: Request,
|
|
201
|
+
organization: dict = Depends(get_current_organization),
|
|
202
|
+
):
|
|
203
|
+
"""
|
|
204
|
+
Create a new environment.
|
|
205
|
+
|
|
206
|
+
If this is the first environment for the organization, it will trigger
|
|
207
|
+
Temporal Cloud namespace provisioning workflow.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
client = get_supabase()
|
|
211
|
+
|
|
212
|
+
# Check if environment name already exists
|
|
213
|
+
existing = (
|
|
214
|
+
client.table("environments")
|
|
215
|
+
.select("id")
|
|
216
|
+
.eq("organization_id", organization["id"])
|
|
217
|
+
.eq("name", env_data.name)
|
|
218
|
+
.execute()
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if existing.data:
|
|
222
|
+
raise HTTPException(
|
|
223
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
224
|
+
detail=f"Environment with name '{env_data.name}' already exists"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Check if this is the first environment
|
|
228
|
+
all_envs = (
|
|
229
|
+
client.table("environments")
|
|
230
|
+
.select("id")
|
|
231
|
+
.eq("organization_id", organization["id"])
|
|
232
|
+
.execute()
|
|
233
|
+
)
|
|
234
|
+
is_first_env = len(all_envs.data or []) == 0
|
|
235
|
+
|
|
236
|
+
# Check if namespace already exists
|
|
237
|
+
namespace_result = (
|
|
238
|
+
client.table("temporal_namespaces")
|
|
239
|
+
.select("*")
|
|
240
|
+
.eq("organization_id", organization["id"])
|
|
241
|
+
.execute()
|
|
242
|
+
)
|
|
243
|
+
has_namespace = bool(namespace_result.data)
|
|
244
|
+
needs_provisioning = is_first_env and not has_namespace
|
|
245
|
+
|
|
246
|
+
env_id = str(uuid.uuid4())
|
|
247
|
+
now = datetime.utcnow().isoformat()
|
|
248
|
+
|
|
249
|
+
# Set initial status
|
|
250
|
+
initial_status = "provisioning" if needs_provisioning else "ready"
|
|
251
|
+
|
|
252
|
+
env_record = {
|
|
253
|
+
"id": env_id,
|
|
254
|
+
"organization_id": organization["id"],
|
|
255
|
+
"name": env_data.name,
|
|
256
|
+
"display_name": env_data.display_name or env_data.name,
|
|
257
|
+
"description": env_data.description,
|
|
258
|
+
"tags": env_data.tags,
|
|
259
|
+
"settings": env_data.settings,
|
|
260
|
+
"status": initial_status,
|
|
261
|
+
"created_at": now,
|
|
262
|
+
"updated_at": now,
|
|
263
|
+
"created_by": organization.get("user_id"),
|
|
264
|
+
"worker_token": str(uuid.uuid4()),
|
|
265
|
+
"execution_environment": env_data.execution_environment.model_dump() if env_data.execution_environment else {},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
result = client.table("environments").insert(env_record).execute()
|
|
269
|
+
|
|
270
|
+
if not result.data:
|
|
271
|
+
raise HTTPException(
|
|
272
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
273
|
+
detail="Failed to create environment"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
environment = result.data[0]
|
|
277
|
+
|
|
278
|
+
# Trigger namespace provisioning if needed
|
|
279
|
+
if needs_provisioning:
|
|
280
|
+
try:
|
|
281
|
+
from control_plane_api.app.workflows.namespace_provisioning import (
|
|
282
|
+
ProvisionTemporalNamespaceWorkflow,
|
|
283
|
+
ProvisionNamespaceInput,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
temporal_client = await get_temporal_client()
|
|
287
|
+
account_id = os.environ.get("TEMPORAL_CLOUD_ACCOUNT_ID", "default-account")
|
|
288
|
+
|
|
289
|
+
workflow_input = ProvisionNamespaceInput(
|
|
290
|
+
organization_id=organization["id"],
|
|
291
|
+
organization_name=organization.get("name", organization["id"]),
|
|
292
|
+
task_queue_id=env_id,
|
|
293
|
+
account_id=account_id,
|
|
294
|
+
region=os.environ.get("TEMPORAL_CLOUD_REGION", "aws-us-east-1"),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
workflow_handle = await temporal_client.start_workflow(
|
|
298
|
+
ProvisionTemporalNamespaceWorkflow.run,
|
|
299
|
+
workflow_input,
|
|
300
|
+
id=f"provision-namespace-{organization['id']}",
|
|
301
|
+
task_queue="agent-control-plane",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
client.table("environments").update({
|
|
305
|
+
"provisioning_workflow_id": workflow_handle.id,
|
|
306
|
+
"updated_at": datetime.utcnow().isoformat(),
|
|
307
|
+
}).eq("id", env_id).execute()
|
|
308
|
+
|
|
309
|
+
environment["provisioning_workflow_id"] = workflow_handle.id
|
|
310
|
+
|
|
311
|
+
logger.info(
|
|
312
|
+
"namespace_provisioning_workflow_started",
|
|
313
|
+
workflow_id=workflow_handle.id,
|
|
314
|
+
environment_id=env_id,
|
|
315
|
+
org_id=organization["id"],
|
|
316
|
+
)
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.error(
|
|
319
|
+
"failed_to_start_provisioning_workflow",
|
|
320
|
+
error=str(e),
|
|
321
|
+
environment_id=env_id,
|
|
322
|
+
org_id=organization["id"],
|
|
323
|
+
)
|
|
324
|
+
client.table("environments").update({
|
|
325
|
+
"status": "error",
|
|
326
|
+
"error_message": f"Failed to start provisioning: {str(e)}",
|
|
327
|
+
"updated_at": datetime.utcnow().isoformat(),
|
|
328
|
+
}).eq("id", env_id).execute()
|
|
329
|
+
environment["status"] = "error"
|
|
330
|
+
environment["error_message"] = f"Failed to start provisioning: {str(e)}"
|
|
331
|
+
|
|
332
|
+
logger.info(
|
|
333
|
+
"environment_created",
|
|
334
|
+
environment_id=env_id,
|
|
335
|
+
environment_name=environment["name"],
|
|
336
|
+
org_id=organization["id"],
|
|
337
|
+
needs_provisioning=needs_provisioning,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return EnvironmentResponse(
|
|
341
|
+
**environment,
|
|
342
|
+
active_workers=0,
|
|
343
|
+
idle_workers=0,
|
|
344
|
+
busy_workers=0,
|
|
345
|
+
skill_ids=[],
|
|
346
|
+
skills=[],
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
except HTTPException:
|
|
350
|
+
raise
|
|
351
|
+
except Exception as e:
|
|
352
|
+
logger.error("environment_creation_failed", error=str(e), org_id=organization["id"])
|
|
353
|
+
raise HTTPException(
|
|
354
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
355
|
+
detail=f"Failed to create environment: {str(e)}"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@router.get("", response_model=List[EnvironmentResponse])
|
|
360
|
+
async def list_environments(
|
|
361
|
+
request: Request,
|
|
362
|
+
status_filter: str | None = None,
|
|
363
|
+
organization: dict = Depends(get_current_organization),
|
|
364
|
+
):
|
|
365
|
+
"""List all environments in the organization"""
|
|
366
|
+
try:
|
|
367
|
+
client = get_supabase()
|
|
368
|
+
|
|
369
|
+
# Ensure default environment exists
|
|
370
|
+
ensure_default_environment(organization)
|
|
371
|
+
|
|
372
|
+
# Query environments
|
|
373
|
+
query = client.table("environments").select("*").eq("organization_id", organization["id"])
|
|
374
|
+
|
|
375
|
+
if status_filter:
|
|
376
|
+
query = query.eq("status", status_filter)
|
|
377
|
+
|
|
378
|
+
query = query.order("created_at", desc=False)
|
|
379
|
+
result = query.execute()
|
|
380
|
+
|
|
381
|
+
if not result.data:
|
|
382
|
+
return []
|
|
383
|
+
|
|
384
|
+
# BATCH FETCH: Get all skills for all environments in one query
|
|
385
|
+
environment_ids = [env["id"] for env in result.data]
|
|
386
|
+
skills_result = (
|
|
387
|
+
client.table("skill_associations")
|
|
388
|
+
.select("entity_id, skill_id, configuration_override, skills(*)")
|
|
389
|
+
.eq("organization_id", organization["id"])
|
|
390
|
+
.eq("entity_type", "environment")
|
|
391
|
+
.in_("entity_id", environment_ids)
|
|
392
|
+
.execute()
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Group skills by environment_id
|
|
396
|
+
skills_by_env = {}
|
|
397
|
+
for item in skills_result.data or []:
|
|
398
|
+
env_id = item["entity_id"]
|
|
399
|
+
skill_data = item.get("skills")
|
|
400
|
+
if skill_data:
|
|
401
|
+
if env_id not in skills_by_env:
|
|
402
|
+
skills_by_env[env_id] = {"ids": [], "data": []}
|
|
403
|
+
|
|
404
|
+
# Merge configuration with override
|
|
405
|
+
config = skill_data.get("configuration", {})
|
|
406
|
+
override = item.get("configuration_override")
|
|
407
|
+
if override:
|
|
408
|
+
config = {**config, **override}
|
|
409
|
+
|
|
410
|
+
skills_by_env[env_id]["ids"].append(skill_data["id"])
|
|
411
|
+
skills_by_env[env_id]["data"].append({
|
|
412
|
+
"id": skill_data["id"],
|
|
413
|
+
"name": skill_data["name"],
|
|
414
|
+
"type": skill_data["skill_type"],
|
|
415
|
+
"description": skill_data.get("description"),
|
|
416
|
+
"enabled": skill_data.get("enabled", True),
|
|
417
|
+
"configuration": config,
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
# Build environment responses
|
|
421
|
+
environments = []
|
|
422
|
+
for env in result.data:
|
|
423
|
+
env_skills = skills_by_env.get(env["id"], {"ids": [], "data": []})
|
|
424
|
+
|
|
425
|
+
environments.append(
|
|
426
|
+
EnvironmentResponse(
|
|
427
|
+
**env,
|
|
428
|
+
active_workers=0,
|
|
429
|
+
idle_workers=0,
|
|
430
|
+
busy_workers=0,
|
|
431
|
+
skill_ids=env_skills["ids"],
|
|
432
|
+
skills=env_skills["data"],
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
logger.info(
|
|
437
|
+
"environments_listed",
|
|
438
|
+
count=len(environments),
|
|
439
|
+
org_id=organization["id"],
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
return environments
|
|
443
|
+
|
|
444
|
+
except Exception as e:
|
|
445
|
+
logger.error("environments_list_failed", error=str(e), org_id=organization["id"])
|
|
446
|
+
raise HTTPException(
|
|
447
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
448
|
+
detail=f"Failed to list environments: {str(e)}"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@router.get("/{environment_id}", response_model=EnvironmentResponse)
|
|
453
|
+
async def get_environment(
|
|
454
|
+
environment_id: str,
|
|
455
|
+
request: Request,
|
|
456
|
+
organization: dict = Depends(get_current_organization),
|
|
457
|
+
):
|
|
458
|
+
"""Get a specific environment by ID"""
|
|
459
|
+
try:
|
|
460
|
+
client = get_supabase()
|
|
461
|
+
|
|
462
|
+
result = (
|
|
463
|
+
client.table("environments")
|
|
464
|
+
.select("*")
|
|
465
|
+
.eq("id", environment_id)
|
|
466
|
+
.eq("organization_id", organization["id"])
|
|
467
|
+
.single()
|
|
468
|
+
.execute()
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
if not result.data:
|
|
472
|
+
raise HTTPException(status_code=404, detail="Environment not found")
|
|
473
|
+
|
|
474
|
+
environment = result.data
|
|
475
|
+
|
|
476
|
+
# Get skills
|
|
477
|
+
skill_ids, skills = get_environment_skills(client, organization["id"], environment_id)
|
|
478
|
+
|
|
479
|
+
return EnvironmentResponse(
|
|
480
|
+
**environment,
|
|
481
|
+
active_workers=0,
|
|
482
|
+
idle_workers=0,
|
|
483
|
+
busy_workers=0,
|
|
484
|
+
skill_ids=skill_ids,
|
|
485
|
+
skills=skills,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
except HTTPException:
|
|
489
|
+
raise
|
|
490
|
+
except Exception as e:
|
|
491
|
+
logger.error("environment_get_failed", error=str(e), environment_id=environment_id)
|
|
492
|
+
raise HTTPException(
|
|
493
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
494
|
+
detail=f"Failed to get environment: {str(e)}"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@router.patch("/{environment_id}", response_model=EnvironmentResponse)
|
|
499
|
+
async def update_environment(
|
|
500
|
+
environment_id: str,
|
|
501
|
+
env_data: EnvironmentUpdate,
|
|
502
|
+
request: Request,
|
|
503
|
+
organization: dict = Depends(get_current_organization),
|
|
504
|
+
):
|
|
505
|
+
"""Update an environment"""
|
|
506
|
+
try:
|
|
507
|
+
client = get_supabase()
|
|
508
|
+
|
|
509
|
+
# Check if environment exists
|
|
510
|
+
existing = (
|
|
511
|
+
client.table("environments")
|
|
512
|
+
.select("id")
|
|
513
|
+
.eq("id", environment_id)
|
|
514
|
+
.eq("organization_id", organization["id"])
|
|
515
|
+
.execute()
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if not existing.data:
|
|
519
|
+
raise HTTPException(status_code=404, detail="Environment not found")
|
|
520
|
+
|
|
521
|
+
# Build update dict
|
|
522
|
+
update_data = env_data.model_dump(exclude_unset=True)
|
|
523
|
+
|
|
524
|
+
# Convert execution_environment Pydantic model to dict if present
|
|
525
|
+
if "execution_environment" in update_data and update_data["execution_environment"]:
|
|
526
|
+
if hasattr(update_data["execution_environment"], "model_dump"):
|
|
527
|
+
update_data["execution_environment"] = update_data["execution_environment"].model_dump()
|
|
528
|
+
|
|
529
|
+
update_data["updated_at"] = datetime.utcnow().isoformat()
|
|
530
|
+
|
|
531
|
+
# Update environment
|
|
532
|
+
result = (
|
|
533
|
+
client.table("environments")
|
|
534
|
+
.update(update_data)
|
|
535
|
+
.eq("id", environment_id)
|
|
536
|
+
.eq("organization_id", organization["id"])
|
|
537
|
+
.execute()
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if not result.data:
|
|
541
|
+
raise HTTPException(
|
|
542
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
543
|
+
detail="Failed to update environment"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
environment = result.data[0]
|
|
547
|
+
|
|
548
|
+
# Get skills
|
|
549
|
+
skill_ids, skills = get_environment_skills(client, organization["id"], environment_id)
|
|
550
|
+
|
|
551
|
+
logger.info(
|
|
552
|
+
"environment_updated",
|
|
553
|
+
environment_id=environment_id,
|
|
554
|
+
org_id=organization["id"],
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
return EnvironmentResponse(
|
|
558
|
+
**environment,
|
|
559
|
+
active_workers=0,
|
|
560
|
+
idle_workers=0,
|
|
561
|
+
busy_workers=0,
|
|
562
|
+
skill_ids=skill_ids,
|
|
563
|
+
skills=skills,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
except HTTPException:
|
|
567
|
+
raise
|
|
568
|
+
except Exception as e:
|
|
569
|
+
logger.error("environment_update_failed", error=str(e), environment_id=environment_id)
|
|
570
|
+
raise HTTPException(
|
|
571
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
572
|
+
detail=f"Failed to update environment: {str(e)}"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@router.delete("/{environment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
577
|
+
async def delete_environment(
|
|
578
|
+
environment_id: str,
|
|
579
|
+
request: Request,
|
|
580
|
+
organization: dict = Depends(get_current_organization),
|
|
581
|
+
):
|
|
582
|
+
"""Delete an environment"""
|
|
583
|
+
try:
|
|
584
|
+
client = get_supabase()
|
|
585
|
+
|
|
586
|
+
# Prevent deleting default environment
|
|
587
|
+
env_check = (
|
|
588
|
+
client.table("environments")
|
|
589
|
+
.select("name")
|
|
590
|
+
.eq("id", environment_id)
|
|
591
|
+
.eq("organization_id", organization["id"])
|
|
592
|
+
.single()
|
|
593
|
+
.execute()
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
if env_check.data and env_check.data.get("name") == "default":
|
|
597
|
+
raise HTTPException(
|
|
598
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
599
|
+
detail="Cannot delete the default environment"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
result = (
|
|
603
|
+
client.table("environments")
|
|
604
|
+
.delete()
|
|
605
|
+
.eq("id", environment_id)
|
|
606
|
+
.eq("organization_id", organization["id"])
|
|
607
|
+
.execute()
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
if not result.data:
|
|
611
|
+
raise HTTPException(status_code=404, detail="Environment not found")
|
|
612
|
+
|
|
613
|
+
logger.info("environment_deleted", environment_id=environment_id, org_id=organization["id"])
|
|
614
|
+
|
|
615
|
+
return None
|
|
616
|
+
|
|
617
|
+
except HTTPException:
|
|
618
|
+
raise
|
|
619
|
+
except Exception as e:
|
|
620
|
+
logger.error("environment_delete_failed", error=str(e), environment_id=environment_id)
|
|
621
|
+
raise HTTPException(
|
|
622
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
623
|
+
detail=f"Failed to delete environment: {str(e)}"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@router.get("/{environment_id}/worker-command", response_model=WorkerCommandResponse)
|
|
628
|
+
async def get_worker_registration_command(
|
|
629
|
+
environment_id: str,
|
|
630
|
+
request: Request,
|
|
631
|
+
organization: dict = Depends(get_current_organization),
|
|
632
|
+
):
|
|
633
|
+
"""
|
|
634
|
+
Get the worker registration command for an environment.
|
|
635
|
+
|
|
636
|
+
Returns the kubiya worker start command with the worker token.
|
|
637
|
+
"""
|
|
638
|
+
try:
|
|
639
|
+
client = get_supabase()
|
|
640
|
+
|
|
641
|
+
# Get environment
|
|
642
|
+
result = (
|
|
643
|
+
client.table("environments")
|
|
644
|
+
.select("*")
|
|
645
|
+
.eq("id", environment_id)
|
|
646
|
+
.eq("organization_id", organization["id"])
|
|
647
|
+
.single()
|
|
648
|
+
.execute()
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
if not result.data:
|
|
652
|
+
raise HTTPException(status_code=404, detail="Environment not found")
|
|
653
|
+
|
|
654
|
+
environment = result.data
|
|
655
|
+
worker_token = environment.get("worker_token")
|
|
656
|
+
|
|
657
|
+
# Generate worker_token if it doesn't exist
|
|
658
|
+
if not worker_token:
|
|
659
|
+
worker_token = str(uuid.uuid4())
|
|
660
|
+
client.table("environments").update({
|
|
661
|
+
"worker_token": worker_token,
|
|
662
|
+
"updated_at": datetime.utcnow().isoformat(),
|
|
663
|
+
}).eq("id", environment_id).execute()
|
|
664
|
+
|
|
665
|
+
logger.info(
|
|
666
|
+
"worker_token_generated",
|
|
667
|
+
environment_id=environment_id,
|
|
668
|
+
org_id=organization["id"],
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
environment_name = environment["name"]
|
|
672
|
+
namespace_status = environment.get("status", "unknown")
|
|
673
|
+
provisioning_workflow_id = environment.get("provisioning_workflow_id")
|
|
674
|
+
|
|
675
|
+
# Check if namespace is ready
|
|
676
|
+
can_register = namespace_status in ["ready", "active"]
|
|
677
|
+
|
|
678
|
+
# Build command
|
|
679
|
+
command = f"kubiya worker start --token {worker_token} --environment {environment_name}"
|
|
680
|
+
|
|
681
|
+
command_parts = {
|
|
682
|
+
"binary": "kubiya",
|
|
683
|
+
"subcommand": "worker start",
|
|
684
|
+
"flags": {
|
|
685
|
+
"--token": worker_token,
|
|
686
|
+
"--environment": environment_name,
|
|
687
|
+
},
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
logger.info(
|
|
691
|
+
"worker_command_retrieved",
|
|
692
|
+
environment_id=environment_id,
|
|
693
|
+
can_register=can_register,
|
|
694
|
+
status=namespace_status,
|
|
695
|
+
org_id=organization["id"],
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
return WorkerCommandResponse(
|
|
699
|
+
worker_token=worker_token,
|
|
700
|
+
environment_name=environment_name,
|
|
701
|
+
command=command,
|
|
702
|
+
command_parts=command_parts,
|
|
703
|
+
namespace_status=namespace_status,
|
|
704
|
+
can_register=can_register,
|
|
705
|
+
provisioning_workflow_id=provisioning_workflow_id,
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
except HTTPException:
|
|
709
|
+
raise
|
|
710
|
+
except Exception as e:
|
|
711
|
+
logger.error("worker_command_get_failed", error=str(e), environment_id=environment_id)
|
|
712
|
+
raise HTTPException(
|
|
713
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
714
|
+
detail=f"Failed to get worker command: {str(e)}"
|
|
715
|
+
)
|