Skip to main content

syncable_cli/agent/tools/platform/
provision_registry.rs

1//! Provision registry tool for the agent
2//!
3//! Allows the agent to provision a new container registry for storing images.
4
5use rig::completion::ToolDefinition;
6use rig::tool::Tool;
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9use std::time::Duration;
10use tokio::time::sleep;
11
12use crate::agent::tools::error::{ErrorCategory, format_error_for_llm};
13use crate::platform::api::types::{CreateRegistryRequest, RegistryTaskState};
14use crate::platform::api::{PlatformApiClient, PlatformApiError};
15
16/// Maximum time to wait for registry provisioning (5 minutes)
17const PROVISIONING_TIMEOUT_SECS: u64 = 300;
18/// Polling interval between status checks
19const POLL_INTERVAL_SECS: u64 = 3;
20
21/// Arguments for the provision registry tool
22#[derive(Debug, Deserialize)]
23pub struct ProvisionRegistryArgs {
24    /// The project UUID
25    pub project_id: String,
26    /// Cluster ID to associate registry with
27    pub cluster_id: String,
28    /// Cluster name for display
29    pub cluster_name: String,
30    /// Cloud provider: "gcp" or "hetzner"
31    pub provider: String,
32    /// Region for the registry
33    pub region: String,
34    /// Name for the registry (auto-generated if not provided)
35    pub registry_name: Option<String>,
36    /// GCP project ID (required for GCP provider)
37    pub gcp_project_id: Option<String>,
38}
39
40/// Error type for provision registry operations
41#[derive(Debug, thiserror::Error)]
42#[error("Provision registry error: {0}")]
43pub struct ProvisionRegistryError(String);
44
45/// Tool to provision a new container registry
46///
47/// Creates a container registry for storing Docker images used in deployments.
48#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49pub struct ProvisionRegistryTool;
50
51impl ProvisionRegistryTool {
52    /// Create a new ProvisionRegistryTool
53    pub fn new() -> Self {
54        Self
55    }
56}
57
58impl Tool for ProvisionRegistryTool {
59    const NAME: &'static str = "provision_registry";
60
61    type Error = ProvisionRegistryError;
62    type Args = ProvisionRegistryArgs;
63    type Output = String;
64
65    async fn definition(&self, _prompt: String) -> ToolDefinition {
66        ToolDefinition {
67            name: Self::NAME.to_string(),
68            description: r#"Provision a new container registry for storing Docker images.
69
70A container registry is required for deployments. This tool starts provisioning
71and polls until completion (may take 1-3 minutes).
72
73**Parameters:**
74- project_id: The project UUID
75- cluster_id: Cluster ID to associate the registry with
76- cluster_name: Cluster name for display purposes
77- provider: "gcp" or "hetzner"
78- region: Region for the registry (e.g., "us-central1", "nbg1")
79- registry_name: Name for the registry (optional - defaults to "main")
80- gcp_project_id: Required for GCP provider
81
82**Prerequisites:**
83- User must be authenticated
84- Provider must be connected
85- Cluster must exist (use list_deployment_capabilities to find clusters)
86
87**Async Behavior:**
88- Provisioning takes 1-3 minutes
89- This tool polls until complete or failed
90- Returns registry details on success
91
92**Returns:**
93- registry_id: The created registry ID
94- registry_name, region, provider
95- registry_url: URL for pushing images
96- status: "completed" or error details"#
97                .to_string(),
98            parameters: json!({
99                "type": "object",
100                "properties": {
101                    "project_id": {
102                        "type": "string",
103                        "description": "The UUID of the project"
104                    },
105                    "cluster_id": {
106                        "type": "string",
107                        "description": "Cluster ID to associate registry with"
108                    },
109                    "cluster_name": {
110                        "type": "string",
111                        "description": "Cluster name for display"
112                    },
113                    "provider": {
114                        "type": "string",
115                        "enum": ["gcp", "hetzner"],
116                        "description": "Cloud provider"
117                    },
118                    "region": {
119                        "type": "string",
120                        "description": "Region for the registry"
121                    },
122                    "registry_name": {
123                        "type": "string",
124                        "description": "Name for the registry (defaults to 'main')"
125                    },
126                    "gcp_project_id": {
127                        "type": "string",
128                        "description": "GCP project ID (required for GCP)"
129                    }
130                },
131                "required": ["project_id", "cluster_id", "cluster_name", "provider", "region"]
132            }),
133        }
134    }
135
136    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
137        // Validate required fields
138        if args.project_id.trim().is_empty() {
139            return Ok(format_error_for_llm(
140                "provision_registry",
141                ErrorCategory::ValidationFailed,
142                "project_id cannot be empty",
143                Some(vec!["Use list_projects to find valid project IDs"]),
144            ));
145        }
146
147        if args.cluster_id.trim().is_empty() {
148            return Ok(format_error_for_llm(
149                "provision_registry",
150                ErrorCategory::ValidationFailed,
151                "cluster_id cannot be empty",
152                Some(vec![
153                    "Use list_deployment_capabilities to find available clusters",
154                ]),
155            ));
156        }
157
158        // Validate provider
159        let valid_providers = ["gcp", "hetzner"];
160        if !valid_providers.contains(&args.provider.as_str()) {
161            return Ok(format_error_for_llm(
162                "provision_registry",
163                ErrorCategory::ValidationFailed,
164                &format!(
165                    "Invalid provider '{}'. Must be 'gcp' or 'hetzner'",
166                    args.provider
167                ),
168                Some(vec![
169                    "Use list_deployment_capabilities to see connected providers",
170                ]),
171            ));
172        }
173
174        // GCP requires gcp_project_id
175        if args.provider == "gcp" && args.gcp_project_id.is_none() {
176            return Ok(format_error_for_llm(
177                "provision_registry",
178                ErrorCategory::ValidationFailed,
179                "gcp_project_id is required for GCP provider",
180                Some(vec![
181                    "The GCP project ID can be found in the GCP Console",
182                    "This is different from the Syncable project_id",
183                ]),
184            ));
185        }
186
187        // Create the API client
188        let client = match PlatformApiClient::new() {
189            Ok(c) => c,
190            Err(e) => {
191                return Ok(format_api_error("provision_registry", e));
192            }
193        };
194
195        // Generate registry name if not provided
196        let registry_name = args
197            .registry_name
198            .as_deref()
199            .map(sanitize_registry_name)
200            .unwrap_or_else(|| "main".to_string());
201
202        // Build the request
203        let request = CreateRegistryRequest {
204            project_id: args.project_id.clone(),
205            cluster_id: args.cluster_id.clone(),
206            cluster_name: args.cluster_name.clone(),
207            registry_name: registry_name.clone(),
208            cloud_provider: args.provider.clone(),
209            region: args.region.clone(),
210            gcp_project_id: args.gcp_project_id.clone(),
211        };
212
213        // Start provisioning
214        let response = match client.create_registry(&args.project_id, &request).await {
215            Ok(r) => r,
216            Err(e) => {
217                return Ok(format_api_error("provision_registry", e));
218            }
219        };
220
221        let task_id = response.task_id;
222
223        // Poll for completion with timeout
224        let start = std::time::Instant::now();
225        loop {
226            if start.elapsed().as_secs() > PROVISIONING_TIMEOUT_SECS {
227                return Ok(format_error_for_llm(
228                    "provision_registry",
229                    ErrorCategory::Timeout,
230                    &format!(
231                        "Registry provisioning timed out after {} seconds. Task ID: {}",
232                        PROVISIONING_TIMEOUT_SECS, task_id
233                    ),
234                    Some(vec![
235                        "The provisioning may still complete in the background",
236                        "Use the platform UI to check the registry status",
237                        &format!("Task ID for reference: {}", task_id),
238                    ]),
239                ));
240            }
241
242            sleep(Duration::from_secs(POLL_INTERVAL_SECS)).await;
243
244            let status = match client.get_registry_task_status(&task_id).await {
245                Ok(s) => s,
246                Err(e) => {
247                    return Ok(format_error_for_llm(
248                        "provision_registry",
249                        ErrorCategory::NetworkError,
250                        &format!("Failed to get task status: {}", e),
251                        Some(vec![
252                            "The provisioning may still be running",
253                            &format!("Task ID: {}", task_id),
254                        ]),
255                    ));
256                }
257            };
258
259            match status.status {
260                RegistryTaskState::Completed => {
261                    let registry_url = status.output.registry_url.clone();
262                    let final_registry_name = status
263                        .output
264                        .registry_name
265                        .clone()
266                        .unwrap_or_else(|| registry_name.clone());
267
268                    // The task_id serves as the registry identifier for now
269                    // The actual registry ID may be returned in the output after provisioning completes
270                    let result = json!({
271                        "success": true,
272                        "task_id": task_id,
273                        "registry_name": final_registry_name,
274                        "region": args.region,
275                        "provider": args.provider,
276                        "registry_url": registry_url,
277                        "status": "completed",
278                        "message": format!(
279                            "Registry '{}' provisioned successfully",
280                            final_registry_name
281                        ),
282                        "next_steps": [
283                            "The registry is now ready for use",
284                            "Use list_deployment_capabilities to get the full registry details",
285                            "Docker images will be pushed to this registry during deployments"
286                        ]
287                    });
288
289                    return serde_json::to_string_pretty(&result).map_err(|e| {
290                        ProvisionRegistryError(format!("Failed to serialize: {}", e))
291                    });
292                }
293                RegistryTaskState::Failed => {
294                    let error_msg = status
295                        .error
296                        .map(|e| e.message)
297                        .unwrap_or_else(|| "Unknown error".to_string());
298
299                    return Ok(format_error_for_llm(
300                        "provision_registry",
301                        ErrorCategory::ExternalCommandFailed,
302                        &format!("Registry provisioning failed: {}", error_msg),
303                        Some(vec![
304                            "Check provider connectivity",
305                            "Verify cluster and region are valid",
306                            "The provider may have resource limits",
307                        ]),
308                    ));
309                }
310                RegistryTaskState::Cancelled => {
311                    return Ok(format_error_for_llm(
312                        "provision_registry",
313                        ErrorCategory::UserCancelled,
314                        "Registry provisioning was cancelled",
315                        Some(vec!["The task was cancelled externally"]),
316                    ));
317                }
318                RegistryTaskState::Processing | RegistryTaskState::Unknown => {
319                    // Continue polling
320                }
321            }
322        }
323    }
324}
325
326/// Sanitize registry name (lowercase, alphanumeric, hyphens)
327fn sanitize_registry_name(name: &str) -> String {
328    name.to_lowercase()
329        .chars()
330        .map(|c| {
331            if c.is_alphanumeric() || c == '-' {
332                c
333            } else {
334                '-'
335            }
336        })
337        .collect::<String>()
338        .trim_matches('-')
339        .to_string()
340}
341
342/// Format a PlatformApiError for LLM consumption
343fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
344    match error {
345        PlatformApiError::Unauthorized => format_error_for_llm(
346            tool_name,
347            ErrorCategory::PermissionDenied,
348            "Not authenticated - please run `sync-ctl auth login` first",
349            Some(vec!["Run: sync-ctl auth login"]),
350        ),
351        PlatformApiError::NotFound(msg) => format_error_for_llm(
352            tool_name,
353            ErrorCategory::ResourceUnavailable,
354            &format!("Resource not found: {}", msg),
355            Some(vec![
356                "The project or cluster ID may be incorrect",
357                "Use list_deployment_capabilities to find valid IDs",
358            ]),
359        ),
360        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
361            tool_name,
362            ErrorCategory::PermissionDenied,
363            &format!("Permission denied: {}", msg),
364            Some(vec!["Contact the project admin for access"]),
365        ),
366        PlatformApiError::RateLimited => format_error_for_llm(
367            tool_name,
368            ErrorCategory::ResourceUnavailable,
369            "Rate limit exceeded",
370            Some(vec!["Wait a moment before retrying"]),
371        ),
372        PlatformApiError::HttpError(e) => format_error_for_llm(
373            tool_name,
374            ErrorCategory::NetworkError,
375            &format!("Network error: {}", e),
376            Some(vec!["Check network connectivity"]),
377        ),
378        PlatformApiError::ParseError(msg) => format_error_for_llm(
379            tool_name,
380            ErrorCategory::InternalError,
381            &format!("Failed to parse API response: {}", msg),
382            None,
383        ),
384        PlatformApiError::ApiError { status, message } => format_error_for_llm(
385            tool_name,
386            ErrorCategory::ExternalCommandFailed,
387            &format!("API error ({}): {}", status, message),
388            Some(vec!["Check the error message for details"]),
389        ),
390        PlatformApiError::ServerError { status, message } => format_error_for_llm(
391            tool_name,
392            ErrorCategory::ExternalCommandFailed,
393            &format!("Server error ({}): {}", status, message),
394            Some(vec!["Try again later"]),
395        ),
396        PlatformApiError::ConnectionFailed => format_error_for_llm(
397            tool_name,
398            ErrorCategory::NetworkError,
399            "Could not connect to Syncable API",
400            Some(vec!["Check your internet connection"]),
401        ),
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_tool_name() {
411        assert_eq!(ProvisionRegistryTool::NAME, "provision_registry");
412    }
413
414    #[test]
415    fn test_tool_creation() {
416        let tool = ProvisionRegistryTool::new();
417        assert!(format!("{:?}", tool).contains("ProvisionRegistryTool"));
418    }
419
420    #[test]
421    fn test_sanitize_registry_name() {
422        assert_eq!(sanitize_registry_name("My Registry"), "my-registry");
423        assert_eq!(sanitize_registry_name("test_name"), "test-name");
424        assert_eq!(sanitize_registry_name("--test--"), "test");
425        assert_eq!(sanitize_registry_name("MAIN"), "main");
426    }
427}