1use 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#[derive(Debug, Deserialize)]
21pub struct CreateDeploymentConfigArgs {
22 pub service_name: String,
24 pub repository_id: i64,
26 pub repository_full_name: String,
28 pub port: i32,
30 pub branch: String,
32 pub target_type: String,
34 pub provider: String,
36 pub environment_id: String,
38 pub dockerfile_path: Option<String>,
40 pub build_context: Option<String>,
42 pub cluster_id: Option<String>,
44 pub registry_id: Option<String>,
46 #[serde(default = "default_auto_deploy")]
48 pub auto_deploy_enabled: bool,
49 pub cpu: Option<String>,
51 pub memory: Option<String>,
53 pub min_instances: Option<i32>,
55 pub max_instances: Option<i32>,
57 pub is_public: Option<bool>,
59}
60
61fn default_auto_deploy() -> bool {
62 true
63}
64
65#[derive(Debug, thiserror::Error)]
67#[error("Create deployment config error: {0}")]
68pub struct CreateDeploymentConfigError(String);
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
74pub struct CreateDeploymentConfigTool;
75
76impl CreateDeploymentConfigTool {
77 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 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 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 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 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 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 let cloud_runner_config = if args.target_type == "cloud_runner" {
316 let provider_enum = CloudProvider::from_str(&args.provider).ok();
317
318 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, 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 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(), build_context: args.build_context.clone(),
366 context: args.build_context.clone(), 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 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
414fn 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}