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!["Use list_deployment_capabilities to find available clusters"]),
153 ));
154 }
155
156 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 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 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 let registry_name = args
195 .registry_name
196 .as_deref()
197 .map(sanitize_registry_name)
198 .unwrap_or_else(|| "main".to_string());
199
200 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 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 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 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 }
318 }
319 }
320 }
321}
322
323fn 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
333fn 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}