syncable_cli/agent/tools/platform/
create_deployment_config.rs

1//! Create deployment config tool for the agent
2//!
3//! Allows the agent to create a new deployment configuration for a service.
4
5use rig::completion::ToolDefinition;
6use rig::tool::Tool;
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9
10use crate::agent::tools::error::{ErrorCategory, format_error_for_llm};
11use crate::platform::api::types::CreateDeploymentConfigRequest;
12use crate::platform::api::{PlatformApiClient, PlatformApiError};
13
14/// Arguments for the create deployment config tool
15#[derive(Debug, Deserialize)]
16pub struct CreateDeploymentConfigArgs {
17    /// The project UUID
18    pub project_id: String,
19    /// Service name for the deployment
20    pub service_name: String,
21    /// Repository ID from GitHub integration
22    pub repository_id: i64,
23    /// Full repository name (e.g., "owner/repo")
24    pub repository_full_name: String,
25    /// Port the service listens on
26    pub port: i32,
27    /// Git branch to deploy from
28    pub branch: String,
29    /// Target type: "kubernetes" or "cloud_runner"
30    pub target_type: String,
31    /// Cloud provider: "gcp" or "hetzner"
32    pub provider: String,
33    /// Environment ID for deployment
34    pub environment_id: String,
35    /// Path to Dockerfile relative to repo root
36    pub dockerfile_path: Option<String>,
37    /// Build context path relative to repo root
38    pub build_context: Option<String>,
39    /// Cluster ID (required for kubernetes target)
40    pub cluster_id: Option<String>,
41    /// Registry ID (optional - will provision new if not provided)
42    pub registry_id: Option<String>,
43    /// Enable auto-deploy on push (defaults to true)
44    #[serde(default = "default_auto_deploy")]
45    pub auto_deploy_enabled: bool,
46}
47
48fn default_auto_deploy() -> bool {
49    true
50}
51
52/// Error type for create deployment config operations
53#[derive(Debug, thiserror::Error)]
54#[error("Create deployment config error: {0}")]
55pub struct CreateDeploymentConfigError(String);
56
57/// Tool to create a new deployment configuration
58///
59/// Creates a deployment config that defines how to build and deploy a service.
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
61pub struct CreateDeploymentConfigTool;
62
63impl CreateDeploymentConfigTool {
64    /// Create a new CreateDeploymentConfigTool
65    pub fn new() -> Self {
66        Self
67    }
68}
69
70impl Tool for CreateDeploymentConfigTool {
71    const NAME: &'static str = "create_deployment_config";
72
73    type Error = CreateDeploymentConfigError;
74    type Args = CreateDeploymentConfigArgs;
75    type Output = String;
76
77    async fn definition(&self, _prompt: String) -> ToolDefinition {
78        ToolDefinition {
79            name: Self::NAME.to_string(),
80            description: r#"Create a new deployment configuration for a service.
81
82A deployment config defines how to build and deploy a service, including:
83- Source repository and branch
84- Dockerfile location and build context
85- Target (Cloud Runner or Kubernetes)
86- Port configuration
87- Auto-deploy settings
88
89**Required Parameters:**
90- project_id: The project UUID
91- service_name: Name for the service (lowercase, hyphens allowed)
92- repository_id: GitHub repository ID (from platform GitHub integration)
93- repository_full_name: Full repo name like "owner/repo"
94- port: Port the service listens on
95- branch: Git branch to deploy from (e.g., "main")
96- target_type: "kubernetes" or "cloud_runner"
97- provider: "gcp" or "hetzner"
98- environment_id: Environment to deploy to
99
100**Optional Parameters:**
101- dockerfile_path: Path to Dockerfile (default: "Dockerfile")
102- build_context: Build context path (default: ".")
103- cluster_id: Required for kubernetes target
104- registry_id: Container registry ID (provisions new if not provided)
105- auto_deploy_enabled: Enable auto-deploy on push (default: true)
106
107**Prerequisites:**
108- User must be authenticated
109- GitHub repository must be connected to the project
110- Provider must be connected (check with check_provider_connection)
111- For kubernetes: cluster must exist (check with list_deployment_capabilities)
112
113**Returns:**
114- config_id: The created deployment config ID
115- service_name, branch, target_type, provider
116- next_steps: How to trigger a deployment"#
117                .to_string(),
118            parameters: json!({
119                "type": "object",
120                "properties": {
121                    "project_id": {
122                        "type": "string",
123                        "description": "The UUID of the project"
124                    },
125                    "service_name": {
126                        "type": "string",
127                        "description": "Name for the service (lowercase, hyphens allowed)"
128                    },
129                    "repository_id": {
130                        "type": "integer",
131                        "description": "GitHub repository ID from platform integration"
132                    },
133                    "repository_full_name": {
134                        "type": "string",
135                        "description": "Full repository name (e.g., 'owner/repo')"
136                    },
137                    "port": {
138                        "type": "integer",
139                        "description": "Port the service listens on"
140                    },
141                    "branch": {
142                        "type": "string",
143                        "description": "Git branch to deploy from"
144                    },
145                    "target_type": {
146                        "type": "string",
147                        "enum": ["kubernetes", "cloud_runner"],
148                        "description": "Deployment target type"
149                    },
150                    "provider": {
151                        "type": "string",
152                        "enum": ["gcp", "hetzner"],
153                        "description": "Cloud provider"
154                    },
155                    "environment_id": {
156                        "type": "string",
157                        "description": "Environment ID for deployment"
158                    },
159                    "dockerfile_path": {
160                        "type": "string",
161                        "description": "Path to Dockerfile relative to repo root"
162                    },
163                    "build_context": {
164                        "type": "string",
165                        "description": "Build context path relative to repo root"
166                    },
167                    "cluster_id": {
168                        "type": "string",
169                        "description": "Cluster ID (required for kubernetes target)"
170                    },
171                    "registry_id": {
172                        "type": "string",
173                        "description": "Registry ID (optional - provisions new if not provided)"
174                    },
175                    "auto_deploy_enabled": {
176                        "type": "boolean",
177                        "description": "Enable auto-deploy on push (default: true)"
178                    }
179                },
180                "required": [
181                    "project_id", "service_name", "repository_id", "repository_full_name",
182                    "port", "branch", "target_type", "provider", "environment_id"
183                ]
184            }),
185        }
186    }
187
188    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
189        // Validate required fields
190        if args.project_id.trim().is_empty() {
191            return Ok(format_error_for_llm(
192                "create_deployment_config",
193                ErrorCategory::ValidationFailed,
194                "project_id cannot be empty",
195                Some(vec![
196                    "Use list_projects to find valid project IDs",
197                    "Use current_context to get the selected project",
198                ]),
199            ));
200        }
201
202        if args.service_name.trim().is_empty() {
203            return Ok(format_error_for_llm(
204                "create_deployment_config",
205                ErrorCategory::ValidationFailed,
206                "service_name cannot be empty",
207                Some(vec![
208                    "Use analyze_project to discover suggested service names",
209                    "Service name should be lowercase with hyphens",
210                ]),
211            ));
212        }
213
214        // Validate target_type
215        let valid_targets = ["kubernetes", "cloud_runner"];
216        if !valid_targets.contains(&args.target_type.as_str()) {
217            return Ok(format_error_for_llm(
218                "create_deployment_config",
219                ErrorCategory::ValidationFailed,
220                &format!(
221                    "Invalid target_type '{}'. Must be 'kubernetes' or 'cloud_runner'",
222                    args.target_type
223                ),
224                Some(vec![
225                    "Use 'cloud_runner' for GCP Cloud Run or Hetzner containers",
226                    "Use 'kubernetes' for deploying to a K8s cluster",
227                ]),
228            ));
229        }
230
231        // Validate provider
232        let valid_providers = ["gcp", "hetzner"];
233        if !valid_providers.contains(&args.provider.as_str()) {
234            return Ok(format_error_for_llm(
235                "create_deployment_config",
236                ErrorCategory::ValidationFailed,
237                &format!(
238                    "Invalid provider '{}'. Must be 'gcp' or 'hetzner'",
239                    args.provider
240                ),
241                Some(vec![
242                    "Use list_deployment_capabilities to see connected providers",
243                    "Connect a provider in platform settings first",
244                ]),
245            ));
246        }
247
248        // Kubernetes target requires cluster_id
249        if args.target_type == "kubernetes" && args.cluster_id.is_none() {
250            return Ok(format_error_for_llm(
251                "create_deployment_config",
252                ErrorCategory::ValidationFailed,
253                "cluster_id is required for kubernetes target",
254                Some(vec![
255                    "Use list_deployment_capabilities to find available clusters",
256                    "Or use 'cloud_runner' target which doesn't require a cluster",
257                ]),
258            ));
259        }
260
261        // Create the API client
262        let client = match PlatformApiClient::new() {
263            Ok(c) => c,
264            Err(e) => {
265                return Ok(format_api_error("create_deployment_config", e));
266            }
267        };
268
269        // Build the request
270        // Note: Send both field name variants (dockerfile/dockerfilePath, context/buildContext)
271        // for backend compatibility - different endpoints may expect different field names
272        let request = CreateDeploymentConfigRequest {
273            project_id: args.project_id.clone(),
274            service_name: args.service_name.clone(),
275            repository_id: args.repository_id,
276            repository_full_name: args.repository_full_name.clone(),
277            dockerfile_path: args.dockerfile_path.clone(),
278            dockerfile: args.dockerfile_path.clone(), // Alias for backend compatibility
279            build_context: args.build_context.clone(),
280            context: args.build_context.clone(), // Alias for backend compatibility
281            port: args.port,
282            branch: args.branch.clone(),
283            target_type: args.target_type.clone(),
284            cloud_provider: args.provider.clone(),
285            environment_id: args.environment_id.clone(),
286            cluster_id: args.cluster_id.clone(),
287            registry_id: args.registry_id.clone(),
288            auto_deploy_enabled: args.auto_deploy_enabled,
289            is_public: None,
290            cloud_runner_config: None,
291        };
292
293        // Create the deployment config
294        match client.create_deployment_config(&request).await {
295            Ok(config) => {
296                let result = json!({
297                    "success": true,
298                    "config_id": config.id,
299                    "service_name": config.service_name,
300                    "branch": config.branch,
301                    "target_type": args.target_type,
302                    "provider": args.provider,
303                    "auto_deploy_enabled": args.auto_deploy_enabled,
304                    "message": format!(
305                        "Deployment config created for service '{}' on {} ({})",
306                        config.service_name, args.target_type, args.provider
307                    ),
308                    "next_steps": [
309                        format!("Use trigger_deployment with config_id '{}' to deploy", config.id),
310                        "Use get_deployment_status to monitor deployment progress",
311                        if args.auto_deploy_enabled {
312                            "Auto-deploy is enabled - pushing to the branch will trigger deployments"
313                        } else {
314                            "Auto-deploy is disabled - deployments must be triggered manually"
315                        }
316                    ]
317                });
318
319                serde_json::to_string_pretty(&result)
320                    .map_err(|e| CreateDeploymentConfigError(format!("Failed to serialize: {}", e)))
321            }
322            Err(e) => Ok(format_api_error("create_deployment_config", e)),
323        }
324    }
325}
326
327/// Format a PlatformApiError for LLM consumption
328fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
329    match error {
330        PlatformApiError::Unauthorized => format_error_for_llm(
331            tool_name,
332            ErrorCategory::PermissionDenied,
333            "Not authenticated - please run `sync-ctl auth login` first",
334            Some(vec![
335                "The user needs to authenticate with the Syncable platform",
336                "Run: sync-ctl auth login",
337            ]),
338        ),
339        PlatformApiError::NotFound(msg) => format_error_for_llm(
340            tool_name,
341            ErrorCategory::ResourceUnavailable,
342            &format!("Resource not found: {}", msg),
343            Some(vec![
344                "The project ID may be incorrect",
345                "The repository may not be connected to the project",
346                "Use list_projects to find valid project IDs",
347            ]),
348        ),
349        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
350            tool_name,
351            ErrorCategory::PermissionDenied,
352            &format!("Permission denied: {}", msg),
353            Some(vec![
354                "The user does not have permission to create deployment configs",
355                "Contact the project admin for access",
356            ]),
357        ),
358        PlatformApiError::RateLimited => format_error_for_llm(
359            tool_name,
360            ErrorCategory::ResourceUnavailable,
361            "Rate limit exceeded - please try again later",
362            Some(vec!["Wait a moment before retrying"]),
363        ),
364        PlatformApiError::HttpError(e) => format_error_for_llm(
365            tool_name,
366            ErrorCategory::NetworkError,
367            &format!("Network error: {}", e),
368            Some(vec![
369                "Check network connectivity",
370                "The Syncable API may be temporarily unavailable",
371            ]),
372        ),
373        PlatformApiError::ParseError(msg) => format_error_for_llm(
374            tool_name,
375            ErrorCategory::InternalError,
376            &format!("Failed to parse API response: {}", msg),
377            Some(vec!["This may be a temporary API issue"]),
378        ),
379        PlatformApiError::ApiError { status, message } => format_error_for_llm(
380            tool_name,
381            ErrorCategory::ExternalCommandFailed,
382            &format!("API error ({}): {}", status, message),
383            Some(vec![
384                "Check the error message for details",
385                "The repository may not be properly connected",
386            ]),
387        ),
388        PlatformApiError::ServerError { status, message } => format_error_for_llm(
389            tool_name,
390            ErrorCategory::ExternalCommandFailed,
391            &format!("Server error ({}): {}", status, message),
392            Some(vec![
393                "The Syncable API is experiencing issues",
394                "Try again later",
395            ]),
396        ),
397        PlatformApiError::ConnectionFailed => format_error_for_llm(
398            tool_name,
399            ErrorCategory::NetworkError,
400            "Could not connect to Syncable API",
401            Some(vec![
402                "Check your internet connection",
403                "The Syncable API may be temporarily unavailable",
404            ]),
405        ),
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn test_tool_name() {
415        assert_eq!(CreateDeploymentConfigTool::NAME, "create_deployment_config");
416    }
417
418    #[test]
419    fn test_tool_creation() {
420        let tool = CreateDeploymentConfigTool::new();
421        assert!(format!("{:?}", tool).contains("CreateDeploymentConfigTool"));
422    }
423
424    #[test]
425    fn test_default_auto_deploy() {
426        assert!(default_auto_deploy());
427    }
428}