Skip to main content

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