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::CreateDeploymentConfigRequest;
12use crate::platform::api::{PlatformApiClient, PlatformApiError};
13
14#[derive(Debug, Deserialize)]
16pub struct CreateDeploymentConfigArgs {
17 pub project_id: String,
19 pub service_name: String,
21 pub repository_id: i64,
23 pub repository_full_name: String,
25 pub port: i32,
27 pub branch: String,
29 pub target_type: String,
31 pub provider: String,
33 pub environment_id: String,
35 pub dockerfile_path: Option<String>,
37 pub build_context: Option<String>,
39 pub cluster_id: Option<String>,
41 pub registry_id: Option<String>,
43 #[serde(default = "default_auto_deploy")]
45 pub auto_deploy_enabled: bool,
46}
47
48fn default_auto_deploy() -> bool {
49 true
50}
51
52#[derive(Debug, thiserror::Error)]
54#[error("Create deployment config error: {0}")]
55pub struct CreateDeploymentConfigError(String);
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
61pub struct CreateDeploymentConfigTool;
62
63impl CreateDeploymentConfigTool {
64 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 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 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 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 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 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 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(), build_context: args.build_context.clone(),
280 context: args.build_context.clone(), 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 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
327fn 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}