1use 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
16const PROVISIONING_TIMEOUT_SECS: u64 = 300;
18const POLL_INTERVAL_SECS: u64 = 3;
20
21#[derive(Debug, Deserialize)]
23pub struct ProvisionRegistryArgs {
24 pub project_id: String,
26 pub cluster_id: String,
28 pub cluster_name: String,
30 pub provider: String,
32 pub region: String,
34 pub registry_name: Option<String>,
36 pub gcp_project_id: Option<String>,
38}
39
40#[derive(Debug, thiserror::Error)]
42#[error("Provision registry error: {0}")]
43pub struct ProvisionRegistryError(String);
44
45#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49pub struct ProvisionRegistryTool;
50
51impl ProvisionRegistryTool {
52 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 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 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 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 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 let registry_name = args
197 .registry_name
198 .as_deref()
199 .map(sanitize_registry_name)
200 .unwrap_or_else(|| "main".to_string());
201
202 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 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 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 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 }
321 }
322 }
323 }
324}
325
326fn 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
342fn 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}