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 std::str::FromStr;
17
18#[derive(Debug, Deserialize)]
20pub struct CreateDeploymentConfigArgs {
21 pub project_id: String,
23 pub service_name: String,
25 pub repository_id: i64,
27 pub repository_full_name: String,
29 pub port: i32,
31 pub branch: String,
33 pub target_type: String,
35 pub provider: String,
37 pub environment_id: String,
39 pub dockerfile_path: Option<String>,
41 pub build_context: Option<String>,
43 pub cluster_id: Option<String>,
45 pub registry_id: Option<String>,
47 #[serde(default = "default_auto_deploy")]
49 pub auto_deploy_enabled: bool,
50 pub cpu: Option<String>,
52 pub memory: Option<String>,
54 pub min_instances: Option<i32>,
56 pub max_instances: Option<i32>,
58 pub is_public: Option<bool>,
60}
61
62fn default_auto_deploy() -> bool {
63 true
64}
65
66#[derive(Debug, thiserror::Error)]
68#[error("Create deployment config error: {0}")]
69pub struct CreateDeploymentConfigError(String);
70
71#[derive(Debug, Clone, Serialize, Deserialize, Default)]
75pub struct CreateDeploymentConfigTool;
76
77impl CreateDeploymentConfigTool {
78 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 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 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 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 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 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 let cloud_runner_config = if args.target_type == "cloud_runner" {
311 let provider_enum = CloudProvider::from_str(&args.provider).ok();
312
313 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, 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 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(), build_context: args.build_context.clone(),
361 context: args.build_context.clone(), 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 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
409fn 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}