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