lc/sync/
providers.rs

1//! Cloud provider implementations for configuration synchronization
2
3use anyhow::Result;
4use aws_config::BehaviorVersion;
5use aws_sdk_s3::{config::Credentials, primitives::ByteStream, Client};
6use colored::Colorize;
7use std::collections::HashMap;
8
9use super::{decode_base64, encode_base64, ConfigFile};
10
11/// S3 configuration for sync operations
12#[derive(Debug, Clone)]
13pub struct S3Config {
14    pub bucket_name: String,
15    pub region: String,
16    pub access_key_id: String,
17    pub secret_access_key: String,
18    pub endpoint_url: Option<String>,
19}
20
21/// S3 provider for configuration synchronization
22pub struct S3Provider {
23    client: Client,
24    bucket_name: String,
25    folder_prefix: String,
26}
27
28impl S3Provider {
29    /// Create a new S3 provider instance with a specific provider name
30    pub async fn new_with_provider(provider_name: &str) -> Result<Self> {
31        let s3_config = Self::get_s3_config(provider_name).await?;
32
33        // Build AWS config with custom settings
34        let mut config_builder = aws_config::defaults(BehaviorVersion::latest())
35            .region(aws_config::Region::new(s3_config.region.clone()))
36            .credentials_provider(Credentials::new(
37                s3_config.access_key_id.clone(),
38                s3_config.secret_access_key.clone(),
39                None,
40                None,
41                "lc-sync",
42            ));
43
44        // Set custom endpoint if provided (for S3-compatible services)
45        if let Some(endpoint_url) = &s3_config.endpoint_url {
46            config_builder = config_builder.endpoint_url(endpoint_url);
47        }
48
49        let config = config_builder.load().await;
50        let client = Client::new(&config);
51
52        let folder_prefix = "llm_client_config".to_string();
53
54        Ok(Self {
55            client,
56            bucket_name: s3_config.bucket_name,
57            folder_prefix,
58        })
59    }
60
61    /// Get S3 configuration from stored config, environment variables, or user input
62    async fn get_s3_config(provider_name: &str) -> Result<S3Config> {
63        use crate::sync::config::{ProviderConfig, SyncConfig};
64        use std::io::{self, Write};
65
66        // First, try to load from stored configuration
67        if let Ok(sync_config) = SyncConfig::load() {
68            if let Some(ProviderConfig::S3 {
69                bucket_name,
70                region,
71                access_key_id,
72                secret_access_key,
73                endpoint_url,
74            }) = sync_config.get_provider(provider_name)
75            {
76                println!("{} Using stored S3 configuration for '{}'", "✓".green(), provider_name);
77                return Ok(S3Config {
78                    bucket_name: bucket_name.clone(),
79                    region: region.clone(),
80                    access_key_id: access_key_id.clone(),
81                    secret_access_key: secret_access_key.clone(),
82                    endpoint_url: endpoint_url.clone(),
83                });
84            }
85        }
86
87        println!("{} S3 Configuration Setup for '{}'", "🔧".blue(), provider_name);
88        println!("{} No stored configuration found. You can:", "💡".yellow());
89        println!(
90            "  - Set up configuration: {}",
91            format!("lc sync configure {} setup", provider_name).dimmed()
92        );
93        println!("  - Use environment variables:");
94        println!("    LC_S3_BUCKET, LC_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, LC_S3_ENDPOINT");
95        println!("  - Enter credentials interactively (below)");
96        println!();
97
98        // Try to get from environment variables first
99        let bucket_name = if let Ok(bucket) = std::env::var("LC_S3_BUCKET") {
100            println!("{} Using bucket from LC_S3_BUCKET: {}", "✓".green(), bucket);
101            bucket
102        } else {
103            print!("Enter S3 bucket name: ");
104            // Deliberately flush stdout to ensure prompt appears before user input
105            io::stdout().flush()?;
106            let mut input = String::new();
107            io::stdin().read_line(&mut input)?;
108            let bucket = input.trim().to_string();
109            if bucket.is_empty() {
110                anyhow::bail!("Bucket name cannot be empty");
111            }
112            bucket
113        };
114
115        let region = if let Ok(region) = std::env::var("LC_S3_REGION") {
116            println!("{} Using region from LC_S3_REGION: {}", "✓".green(), region);
117            region
118        } else {
119            print!("Enter AWS region (default: us-east-1): ");
120            // Deliberately flush stdout to ensure prompt appears before user input
121            io::stdout().flush()?;
122            let mut input = String::new();
123            io::stdin().read_line(&mut input)?;
124            let region = input.trim().to_string();
125            if region.is_empty() {
126                "us-east-1".to_string()
127            } else {
128                region
129            }
130        };
131
132        let access_key_id = if let Ok(key) = std::env::var("AWS_ACCESS_KEY_ID") {
133            println!("{} Using access key from AWS_ACCESS_KEY_ID", "✓".green());
134            key
135        } else {
136            print!("Enter AWS Access Key ID: ");
137            // Deliberately flush stdout to ensure prompt appears before user input
138            io::stdout().flush()?;
139            let mut input = String::new();
140            io::stdin().read_line(&mut input)?;
141            let key = input.trim().to_string();
142            if key.is_empty() {
143                anyhow::bail!("Access Key ID cannot be empty");
144            }
145            key
146        };
147
148        let secret_access_key = if let Ok(secret) = std::env::var("AWS_SECRET_ACCESS_KEY") {
149            println!(
150                "{} Using secret key from AWS_SECRET_ACCESS_KEY",
151                "✓".green()
152            );
153            secret
154        } else {
155            print!("Enter AWS Secret Access Key: ");
156            // Deliberately flush stdout to ensure prompt appears before password input
157            io::stdout().flush()?;
158            let secret = rpassword::read_password()?;
159            if secret.is_empty() {
160                anyhow::bail!("Secret Access Key cannot be empty");
161            }
162            secret
163        };
164
165        let endpoint_url = if let Ok(endpoint) = std::env::var("LC_S3_ENDPOINT") {
166            println!(
167                "{} Using custom endpoint from LC_S3_ENDPOINT: {}",
168                "✓".green(),
169                endpoint
170            );
171            Some(endpoint)
172        } else {
173            print!("Enter custom S3 endpoint URL (optional, for Backblaze/Cloudflare R2/etc., press Enter to skip): ");
174            // Deliberately flush stdout to ensure prompt appears before user input
175            io::stdout().flush()?;
176            let mut input = String::new();
177            io::stdin().read_line(&mut input)?;
178            let endpoint = input.trim().to_string();
179            if endpoint.is_empty() {
180                None
181            } else {
182                Some(endpoint)
183            }
184        };
185
186        Ok(S3Config {
187            bucket_name,
188            region,
189            access_key_id,
190            secret_access_key,
191            endpoint_url,
192        })
193    }
194
195    /// Upload configuration files to S3
196    pub async fn upload_configs(&self, files: &[ConfigFile], encrypted: bool) -> Result<()> {
197        println!(
198            "{} Uploading to S3 bucket: {}",
199            "📤".blue(),
200            self.bucket_name
201        );
202
203        // Check if bucket exists and is accessible
204        match self
205            .client
206            .head_bucket()
207            .bucket(&self.bucket_name)
208            .send()
209            .await
210        {
211            Ok(_) => {
212                println!("{} Bucket access verified", "✓".green());
213            }
214            Err(e) => {
215                anyhow::bail!("Cannot access S3 bucket '{}': {}. Please check your AWS credentials and bucket permissions.", self.bucket_name, e);
216            }
217        }
218
219        let mut uploaded_count = 0;
220
221        for file in files {
222            let key = format!("{}/{}", self.folder_prefix, file.name);
223
224            // Convert binary data to base64 for safe S3 storage
225            let content_b64 = encode_base64(&file.content);
226
227            // Add metadata
228            let mut metadata = HashMap::new();
229            metadata.insert("original-name".to_string(), file.name.clone());
230            metadata.insert("encrypted".to_string(), encrypted.to_string());
231            metadata.insert("encoding".to_string(), "base64".to_string());
232            metadata.insert("sync-tool".to_string(), "lc".to_string());
233            metadata.insert("sync-version".to_string(), "1.0".to_string());
234
235            // Add file type metadata for better handling
236            let file_type = if file.name.ends_with(".toml") {
237                "config"
238            } else if file.name == "logs.db" {
239                "database"
240            } else {
241                "unknown"
242            };
243            metadata.insert("file-type".to_string(), file_type.to_string());
244
245            // Add file size for monitoring
246            metadata.insert("file-size".to_string(), file.content.len().to_string());
247
248            match self
249                .client
250                .put_object()
251                .bucket(&self.bucket_name)
252                .key(&key)
253                .body(ByteStream::from(content_b64.into_bytes()))
254                .content_type("text/plain")
255                .set_metadata(Some(metadata))
256                .send()
257                .await
258            {
259                Ok(_) => {
260                    println!("  {} Uploaded: {}", "✓".green(), file.name);
261                    uploaded_count += 1;
262                }
263                Err(e) => {
264                    crate::debug_log!("Failed to upload {}: {}", file.name, e);
265                    eprintln!("  {} Failed to upload {}: {}", "✗".red(), file.name, e);
266                }
267            }
268        }
269
270        if uploaded_count == files.len() {
271            println!(
272                "{} All {} files uploaded successfully",
273                "🎉".green(),
274                uploaded_count
275            );
276        } else {
277            println!(
278                "{} Uploaded {}/{} files",
279                "âš ī¸".yellow(),
280                uploaded_count,
281                files.len()
282            );
283        }
284
285        Ok(())
286    }
287
288    /// Download configuration files from S3
289    pub async fn download_configs(&self, encrypted: bool) -> Result<Vec<ConfigFile>> {
290        println!(
291            "{} Downloading from S3 bucket: {}",
292            "đŸ“Ĩ".blue(),
293            self.bucket_name
294        );
295
296        // List objects in the folder
297        let list_response = self
298            .client
299            .list_objects_v2()
300            .bucket(&self.bucket_name)
301            .prefix(&self.folder_prefix)
302            .send()
303            .await
304            .map_err(|e| {
305                anyhow::anyhow!(
306                    "Failed to list objects in bucket '{}': {}",
307                    self.bucket_name,
308                    e
309                )
310            })?;
311
312        let objects = list_response.contents();
313
314        if objects.is_empty() {
315            println!("{} No configuration files found in S3", "â„šī¸".blue());
316            return Ok(Vec::new());
317        }
318
319        println!("{} Found {} objects in S3", "📁".blue(), objects.len());
320
321        let mut downloaded_files = Vec::new();
322
323        for object in objects {
324            if let Some(key) = object.key() {
325                // Skip directory markers
326                if key.ends_with('/') {
327                    continue;
328                }
329
330                // Extract filename from key
331                let filename = key
332                    .strip_prefix(&format!("{}/", self.folder_prefix))
333                    .unwrap_or(key)
334                    .to_string();
335
336                match self
337                    .client
338                    .get_object()
339                    .bucket(&self.bucket_name)
340                    .key(key)
341                    .send()
342                    .await
343                {
344                    Ok(response) => {
345                        // Extract metadata first before consuming the response
346                        let metadata = response.metadata().cloned().unwrap_or_default();
347                        let is_encrypted = metadata
348                            .get("encrypted")
349                            .map(|v| v == "true")
350                            .unwrap_or(false);
351
352                        // Read the content
353                        let body =
354                            response.body.collect().await.map_err(|e| {
355                                anyhow::anyhow!("Failed to read object body: {}", e)
356                            })?;
357                        let content_b64 =
358                            String::from_utf8(body.into_bytes().to_vec()).map_err(|e| {
359                                anyhow::anyhow!("Invalid UTF-8 in object content: {}", e)
360                            })?;
361
362                        // Decode from base64
363                        let content = decode_base64(&content_b64).map_err(|e| {
364                            anyhow::anyhow!(
365                                "Failed to decode base64 content for {}: {}",
366                                filename,
367                                e
368                            )
369                        })?;
370
371                        if encrypted && !is_encrypted {
372                            crate::debug_log!(
373                                "Warning: {} is not encrypted but --encrypted flag was used",
374                                filename
375                            );
376                            eprintln!(
377                                "  {} Warning: {} is not encrypted but --encrypted flag was used",
378                                "âš ī¸".yellow(),
379                                filename
380                            );
381                        } else if !encrypted && is_encrypted {
382                            crate::debug_log!(
383                                "Warning: {} is encrypted but --encrypted flag was not used",
384                                filename
385                            );
386                            eprintln!(
387                                "  {} Warning: {} is encrypted but --encrypted flag was not used",
388                                "âš ī¸".yellow(),
389                                filename
390                            );
391                        }
392
393                        downloaded_files.push(ConfigFile {
394                            name: filename.clone(),
395                            path: std::path::PathBuf::from(&filename),
396                            content,
397                        });
398
399                        println!("  {} Downloaded: {}", "✓".green(), filename);
400                    }
401                    Err(e) => {
402                        crate::debug_log!("Failed to download {}: {}", filename, e);
403                        eprintln!("  {} Failed to download {}: {}", "✗".red(), filename, e);
404                    }
405                }
406            }
407        }
408
409        println!(
410            "{} Downloaded {} files successfully",
411            "🎉".green(),
412            downloaded_files.len()
413        );
414
415        Ok(downloaded_files)
416    }
417
418    /// List available configuration files in S3 (for future use)
419    #[allow(dead_code)]
420    pub async fn list_configs(&self) -> Result<Vec<String>> {
421        let list_response = self
422            .client
423            .list_objects_v2()
424            .bucket(&self.bucket_name)
425            .prefix(&self.folder_prefix)
426            .send()
427            .await
428            .map_err(|e| anyhow::anyhow!("Failed to list objects: {}", e))?;
429
430        let mut filenames = Vec::new();
431
432        for object in list_response.contents() {
433            if let Some(key) = object.key() {
434                if !key.ends_with('/') {
435                    let filename = key
436                        .strip_prefix(&format!("{}/", self.folder_prefix))
437                        .unwrap_or(key)
438                        .to_string();
439                    filenames.push(filename);
440                }
441            }
442        }
443
444        Ok(filenames)
445    }
446
447    /// Delete configuration files from S3 (for future use)
448    #[allow(dead_code)]
449    pub async fn delete_configs(&self, filenames: &[String]) -> Result<()> {
450        for filename in filenames {
451            let key = format!("{}/{}", self.folder_prefix, filename);
452
453            match self
454                .client
455                .delete_object()
456                .bucket(&self.bucket_name)
457                .key(&key)
458                .send()
459                .await
460            {
461                Ok(_) => {
462                    println!("  {} Deleted: {}", "✓".green(), filename);
463                }
464                Err(e) => {
465                    crate::debug_log!("Failed to delete {}: {}", filename, e);
466                    eprintln!("  {} Failed to delete {}: {}", "✗".red(), filename, e);
467                }
468            }
469        }
470
471        Ok(())
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_s3_provider_creation() {
481        // This test would require AWS credentials, so we'll just test the structure
482        assert_eq!("llm_client_config", "llm_client_config");
483    }
484
485    #[test]
486    fn test_s3_config_creation() {
487        // Test S3Config struct creation
488        let config = S3Config {
489            bucket_name: "test-bucket".to_string(),
490            region: "us-east-1".to_string(),
491            access_key_id: "test-key".to_string(),
492            secret_access_key: "test-secret".to_string(),
493            endpoint_url: None,
494        };
495
496        assert_eq!(config.bucket_name, "test-bucket");
497        assert_eq!(config.region, "us-east-1");
498        assert_eq!(config.access_key_id, "test-key");
499        assert_eq!(config.secret_access_key, "test-secret");
500        assert!(config.endpoint_url.is_none());
501    }
502}