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!["Use list_deployment_capabilities to find available clusters"]),
153            ));
154        }
155
156        // Validate provider
157        let valid_providers = ["gcp", "hetzner"];
158        if !valid_providers.contains(&args.provider.as_str()) {
159            return Ok(format_error_for_llm(
160                "provision_registry",
161                ErrorCategory::ValidationFailed,
162                &format!(
163                    "Invalid provider '{}'. Must be 'gcp' or 'hetzner'",
164                    args.provider
165                ),
166                Some(vec![
167                    "Use list_deployment_capabilities to see connected providers",
168                ]),
169            ));
170        }
171
172        // GCP requires gcp_project_id
173        if args.provider == "gcp" && args.gcp_project_id.is_none() {
174            return Ok(format_error_for_llm(
175                "provision_registry",
176                ErrorCategory::ValidationFailed,
177                "gcp_project_id is required for GCP provider",
178                Some(vec![
179                    "The GCP project ID can be found in the GCP Console",
180                    "This is different from the Syncable project_id",
181                ]),
182            ));
183        }
184
185        // Create the API client
186        let client = match PlatformApiClient::new() {
187            Ok(c) => c,
188            Err(e) => {
189                return Ok(format_api_error("provision_registry", e));
190            }
191        };
192
193        // Generate registry name if not provided
194        let registry_name = args
195            .registry_name
196            .as_deref()
197            .map(sanitize_registry_name)
198            .unwrap_or_else(|| "main".to_string());
199
200        // Build the request
201        let request = CreateRegistryRequest {
202            project_id: args.project_id.clone(),
203            cluster_id: args.cluster_id.clone(),
204            cluster_name: args.cluster_name.clone(),
205            registry_name: registry_name.clone(),
206            cloud_provider: args.provider.clone(),
207            region: args.region.clone(),
208            gcp_project_id: args.gcp_project_id.clone(),
209        };
210
211        // Start provisioning
212        let response = match client.create_registry(&args.project_id, &request).await {
213            Ok(r) => r,
214            Err(e) => {
215                return Ok(format_api_error("provision_registry", e));
216            }
217        };
218
219        let task_id = response.task_id;
220
221        // Poll for completion with timeout
222        let start = std::time::Instant::now();
223        loop {
224            if start.elapsed().as_secs() > PROVISIONING_TIMEOUT_SECS {
225                return Ok(format_error_for_llm(
226                    "provision_registry",
227                    ErrorCategory::Timeout,
228                    &format!(
229                        "Registry provisioning timed out after {} seconds. Task ID: {}",
230                        PROVISIONING_TIMEOUT_SECS, task_id
231                    ),
232                    Some(vec![
233                        "The provisioning may still complete in the background",
234                        "Use the platform UI to check the registry status",
235                        &format!("Task ID for reference: {}", task_id),
236                    ]),
237                ));
238            }
239
240            sleep(Duration::from_secs(POLL_INTERVAL_SECS)).await;
241
242            let status = match client.get_registry_task_status(&task_id).await {
243                Ok(s) => s,
244                Err(e) => {
245                    return Ok(format_error_for_llm(
246                        "provision_registry",
247                        ErrorCategory::NetworkError,
248                        &format!("Failed to get task status: {}", e),
249                        Some(vec![
250                            "The provisioning may still be running",
251                            &format!("Task ID: {}", task_id),
252                        ]),
253                    ));
254                }
255            };
256
257            match status.status {
258                RegistryTaskState::Completed => {
259                    let registry_url = status.output.registry_url.clone();
260                    let final_registry_name = status
261                        .output
262                        .registry_name
263                        .clone()
264                        .unwrap_or_else(|| registry_name.clone());
265
266                    // The task_id serves as the registry identifier for now
267                    // The actual registry ID may be returned in the output after provisioning completes
268                    let result = json!({
269                        "success": true,
270                        "task_id": task_id,
271                        "registry_name": final_registry_name,
272                        "region": args.region,
273                        "provider": args.provider,
274                        "registry_url": registry_url,
275                        "status": "completed",
276                        "message": format!(
277                            "Registry '{}' provisioned successfully",
278                            final_registry_name
279                        ),
280                        "next_steps": [
281                            "The registry is now ready for use",
282                            "Use list_deployment_capabilities to get the full registry details",
283                            "Docker images will be pushed to this registry during deployments"
284                        ]
285                    });
286
287                    return serde_json::to_string_pretty(&result)
288                        .map_err(|e| ProvisionRegistryError(format!("Failed to serialize: {}", e)));
289                }
290                RegistryTaskState::Failed => {
291                    let error_msg = status
292                        .error
293                        .map(|e| e.message)
294                        .unwrap_or_else(|| "Unknown error".to_string());
295
296                    return Ok(format_error_for_llm(
297                        "provision_registry",
298                        ErrorCategory::ExternalCommandFailed,
299                        &format!("Registry provisioning failed: {}", error_msg),
300                        Some(vec![
301                            "Check provider connectivity",
302                            "Verify cluster and region are valid",
303                            "The provider may have resource limits",
304                        ]),
305                    ));
306                }
307                RegistryTaskState::Cancelled => {
308                    return Ok(format_error_for_llm(
309                        "provision_registry",
310                        ErrorCategory::UserCancelled,
311                        "Registry provisioning was cancelled",
312                        Some(vec!["The task was cancelled externally"]),
313                    ));
314                }
315                RegistryTaskState::Processing | RegistryTaskState::Unknown => {
316                    // Continue polling
317                }
318            }
319        }
320    }
321}
322
323/// Sanitize registry name (lowercase, alphanumeric, hyphens)
324fn sanitize_registry_name(name: &str) -> String {
325    name.to_lowercase()
326        .chars()
327        .map(|c| if c.is_alphanumeric() || c == '-' { c } else { '-' })
328        .collect::<String>()
329        .trim_matches('-')
330        .to_string()
331}
332
333/// Format a PlatformApiError for LLM consumption
334fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
335    match error {
336        PlatformApiError::Unauthorized => format_error_for_llm(
337            tool_name,
338            ErrorCategory::PermissionDenied,
339            "Not authenticated - please run `sync-ctl auth login` first",
340            Some(vec!["Run: sync-ctl auth login"]),
341        ),
342        PlatformApiError::NotFound(msg) => format_error_for_llm(
343            tool_name,
344            ErrorCategory::ResourceUnavailable,
345            &format!("Resource not found: {}", msg),
346            Some(vec![
347                "The project or cluster ID may be incorrect",
348                "Use list_deployment_capabilities to find valid IDs",
349            ]),
350        ),
351        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
352            tool_name,
353            ErrorCategory::PermissionDenied,
354            &format!("Permission denied: {}", msg),
355            Some(vec!["Contact the project admin for access"]),
356        ),
357        PlatformApiError::RateLimited => format_error_for_llm(
358            tool_name,
359            ErrorCategory::ResourceUnavailable,
360            "Rate limit exceeded",
361            Some(vec!["Wait a moment before retrying"]),
362        ),
363        PlatformApiError::HttpError(e) => format_error_for_llm(
364            tool_name,
365            ErrorCategory::NetworkError,
366            &format!("Network error: {}", e),
367            Some(vec!["Check network connectivity"]),
368        ),
369        PlatformApiError::ParseError(msg) => format_error_for_llm(
370            tool_name,
371            ErrorCategory::InternalError,
372            &format!("Failed to parse API response: {}", msg),
373            None,
374        ),
375        PlatformApiError::ApiError { status, message } => format_error_for_llm(
376            tool_name,
377            ErrorCategory::ExternalCommandFailed,
378            &format!("API error ({}): {}", status, message),
379            Some(vec!["Check the error message for details"]),
380        ),
381        PlatformApiError::ServerError { status, message } => format_error_for_llm(
382            tool_name,
383            ErrorCategory::ExternalCommandFailed,
384            &format!("Server error ({}): {}", status, message),
385            Some(vec!["Try again later"]),
386        ),
387        PlatformApiError::ConnectionFailed => format_error_for_llm(
388            tool_name,
389            ErrorCategory::NetworkError,
390            "Could not connect to Syncable API",
391            Some(vec!["Check your internet connection"]),
392        ),
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_tool_name() {
402        assert_eq!(ProvisionRegistryTool::NAME, "provision_registry");
403    }
404
405    #[test]
406    fn test_tool_creation() {
407        let tool = ProvisionRegistryTool::new();
408        assert!(format!("{:?}", tool).contains("ProvisionRegistryTool"));
409    }
410
411    #[test]
412    fn test_sanitize_registry_name() {
413        assert_eq!(sanitize_registry_name("My Registry"), "my-registry");
414        assert_eq!(sanitize_registry_name("test_name"), "test-name");
415        assert_eq!(sanitize_registry_name("--test--"), "test");
416        assert_eq!(sanitize_registry_name("MAIN"), "main");
417    }
418}