Skip to main content

purple_ssh/providers/
sync.rs

1use std::collections::HashMap;
2
3use crate::ssh_config::model::{ConfigElement, HostEntry, SshConfigFile};
4
5use super::config::ProviderSection;
6use super::{Provider, ProviderHost};
7
8/// Result of a sync operation.
9#[derive(Debug, Default)]
10pub struct SyncResult {
11    pub added: usize,
12    pub updated: usize,
13    pub removed: usize,
14    pub unchanged: usize,
15}
16
17/// Sanitize a server name into a valid SSH alias component.
18/// Lowercase, non-alphanumeric chars become hyphens, collapse consecutive hyphens.
19/// Falls back to "server" if the result would be empty (all-symbol/unicode names).
20fn sanitize_name(name: &str) -> String {
21    let mut result = String::new();
22    for c in name.chars() {
23        if c.is_ascii_alphanumeric() {
24            result.push(c.to_ascii_lowercase());
25        } else if !result.ends_with('-') {
26            result.push('-');
27        }
28    }
29    let trimmed = result.trim_matches('-').to_string();
30    if trimmed.is_empty() {
31        "server".to_string()
32    } else {
33        trimmed
34    }
35}
36
37/// Display name for a provider (used in group header comments).
38fn provider_header(name: &str) -> &str {
39    match name {
40        "digitalocean" => "DigitalOcean",
41        "vultr" => "Vultr",
42        "linode" => "Linode",
43        "hetzner" => "Hetzner",
44        "upcloud" => "UpCloud",
45        other => other,
46    }
47}
48
49/// Sync hosts from a cloud provider into the SSH config.
50pub fn sync_provider(
51    config: &mut SshConfigFile,
52    provider: &dyn Provider,
53    remote_hosts: &[ProviderHost],
54    section: &ProviderSection,
55    remove_deleted: bool,
56    dry_run: bool,
57) -> SyncResult {
58    let mut result = SyncResult::default();
59
60    // Build map of server_id -> alias (top-level only, no Include files)
61    let existing = config.find_hosts_by_provider(provider.name());
62    let mut existing_map: HashMap<String, String> = HashMap::new();
63    for (alias, server_id) in &existing {
64        existing_map.insert(server_id.clone(), alias.clone());
65    }
66
67    // Build alias -> HostEntry lookup once (avoids quadratic host_entries() calls)
68    let entries_map: HashMap<String, HostEntry> = config
69        .host_entries()
70        .into_iter()
71        .map(|e| (e.alias.clone(), e))
72        .collect();
73
74    // Track which server IDs are still in the remote set (also deduplicates)
75    let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
76
77    // Only add group header if this provider has no existing hosts in config
78    let mut needs_header = !dry_run && existing_map.is_empty();
79
80    for remote in remote_hosts {
81        if !remote_ids.insert(remote.server_id.clone()) {
82            continue; // Skip duplicate server_id in same response
83        }
84
85        if let Some(existing_alias) = existing_map.get(&remote.server_id) {
86            // Host exists, check if IP or tags changed
87            if let Some(entry) = entries_map.get(existing_alias) {
88                // Included hosts are read-only; recognize them for dedup but skip mutations
89                if entry.source_file.is_some() {
90                    result.unchanged += 1;
91                    continue;
92                }
93                let ip_changed = entry.hostname != remote.ip;
94                let mut sorted_local = entry.tags.clone();
95                sorted_local.sort();
96                let mut sorted_remote = remote.tags.clone();
97                sorted_remote.sort();
98                let tags_changed = sorted_local != sorted_remote;
99                if ip_changed || tags_changed {
100                    if !dry_run {
101                        if ip_changed {
102                            let updated = HostEntry {
103                                hostname: remote.ip.clone(),
104                                ..entry.clone()
105                            };
106                            config.update_host(existing_alias, &updated);
107                        }
108                        if tags_changed {
109                            config.set_host_tags(existing_alias, &remote.tags);
110                        }
111                    }
112                    result.updated += 1;
113                } else {
114                    result.unchanged += 1;
115                }
116            } else {
117                result.unchanged += 1;
118            }
119        } else {
120            // New host
121            let sanitized = sanitize_name(&remote.name);
122            let base_alias = format!("{}-{}", section.alias_prefix, sanitized);
123            let alias = if dry_run {
124                base_alias
125            } else {
126                config.deduplicate_alias(&base_alias)
127            };
128
129            if !dry_run {
130                // Add group header before the very first host for this provider
131                if needs_header {
132                    if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
133                        config
134                            .elements
135                            .push(ConfigElement::GlobalLine(String::new()));
136                    }
137                    config
138                        .elements
139                        .push(ConfigElement::GlobalLine(format!(
140                            "# {}",
141                            provider_header(provider.name())
142                        )));
143                    needs_header = false;
144                }
145
146                let entry = HostEntry {
147                    alias: alias.clone(),
148                    hostname: remote.ip.clone(),
149                    user: section.user.clone(),
150                    port: 22,
151                    identity_file: section.identity_file.clone(),
152                    proxy_jump: String::new(),
153                    source_file: None,
154                    tags: remote.tags.clone(),
155                    provider: Some(provider.name().to_string()),
156                };
157
158                let block = SshConfigFile::entry_to_block(&entry);
159                config.elements.push(ConfigElement::HostBlock(block));
160                config.set_host_provider(&alias, provider.name(), &remote.server_id);
161                if !remote.tags.is_empty() {
162                    config.set_host_tags(&alias, &remote.tags);
163                }
164            }
165
166            result.added += 1;
167        }
168    }
169
170    // Remove deleted hosts (skip included hosts which are read-only)
171    if remove_deleted && !dry_run {
172        let to_remove: Vec<String> = existing_map
173            .iter()
174            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
175            .filter(|(_, alias)| {
176                entries_map
177                    .get(alias.as_str())
178                    .is_none_or(|e| e.source_file.is_none())
179            })
180            .map(|(_, alias)| alias.clone())
181            .collect();
182        for alias in &to_remove {
183            config.delete_host(alias);
184        }
185        result.removed = to_remove.len();
186
187        // Clean up orphan provider header if all hosts for this provider were removed
188        if config.find_hosts_by_provider(provider.name()).is_empty() {
189            let header_text = format!("# {}", provider_header(provider.name()));
190            config
191                .elements
192                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
193        }
194    } else if remove_deleted {
195        result.removed = existing_map
196            .iter()
197            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
198            .filter(|(_, alias)| {
199                entries_map
200                    .get(alias.as_str())
201                    .is_none_or(|e| e.source_file.is_none())
202            })
203            .count();
204    }
205
206    result
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::path::PathBuf;
213
214    fn empty_config() -> SshConfigFile {
215        SshConfigFile {
216            elements: Vec::new(),
217            path: PathBuf::from("/tmp/test_config"),
218            crlf: false,
219        }
220    }
221
222    fn make_section() -> ProviderSection {
223        ProviderSection {
224            provider: "digitalocean".to_string(),
225            token: "test".to_string(),
226            alias_prefix: "do".to_string(),
227            user: "root".to_string(),
228            identity_file: String::new(),
229        }
230    }
231
232    struct MockProvider;
233    impl Provider for MockProvider {
234        fn name(&self) -> &str {
235            "digitalocean"
236        }
237        fn short_label(&self) -> &str {
238            "do"
239        }
240        fn fetch_hosts(
241            &self,
242            _token: &str,
243        ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
244            Ok(Vec::new())
245        }
246    }
247
248    #[test]
249    fn test_sanitize_name() {
250        assert_eq!(sanitize_name("web-1"), "web-1");
251        assert_eq!(sanitize_name("My Server"), "my-server");
252        assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
253        assert_eq!(sanitize_name("--weird--"), "weird");
254        assert_eq!(sanitize_name("UPPER"), "upper");
255        assert_eq!(sanitize_name("a--b"), "a-b");
256        assert_eq!(sanitize_name(""), "server");
257        assert_eq!(sanitize_name("..."), "server");
258    }
259
260    #[test]
261    fn test_sync_adds_new_hosts() {
262        let mut config = empty_config();
263        let section = make_section();
264        let remote = vec![
265            ProviderHost {
266                server_id: "123".to_string(),
267                name: "web-1".to_string(),
268                ip: "1.2.3.4".to_string(),
269                tags: Vec::new(),
270            },
271            ProviderHost {
272                server_id: "456".to_string(),
273                name: "db-1".to_string(),
274                ip: "5.6.7.8".to_string(),
275                tags: Vec::new(),
276            },
277        ];
278
279        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
280        assert_eq!(result.added, 2);
281        assert_eq!(result.updated, 0);
282        assert_eq!(result.unchanged, 0);
283
284        let entries = config.host_entries();
285        assert_eq!(entries.len(), 2);
286        assert_eq!(entries[0].alias, "do-web-1");
287        assert_eq!(entries[0].hostname, "1.2.3.4");
288        assert_eq!(entries[1].alias, "do-db-1");
289    }
290
291    #[test]
292    fn test_sync_updates_changed_ip() {
293        let mut config = empty_config();
294        let section = make_section();
295
296        // First sync: add host
297        let remote = vec![ProviderHost {
298            server_id: "123".to_string(),
299            name: "web-1".to_string(),
300            ip: "1.2.3.4".to_string(),
301            tags: Vec::new(),
302        }];
303        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
304
305        // Second sync: IP changed
306        let remote = vec![ProviderHost {
307            server_id: "123".to_string(),
308            name: "web-1".to_string(),
309            ip: "9.8.7.6".to_string(),
310            tags: Vec::new(),
311        }];
312        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
313        assert_eq!(result.updated, 1);
314        assert_eq!(result.added, 0);
315
316        let entries = config.host_entries();
317        assert_eq!(entries[0].hostname, "9.8.7.6");
318    }
319
320    #[test]
321    fn test_sync_unchanged() {
322        let mut config = empty_config();
323        let section = make_section();
324
325        let remote = vec![ProviderHost {
326            server_id: "123".to_string(),
327            name: "web-1".to_string(),
328            ip: "1.2.3.4".to_string(),
329            tags: Vec::new(),
330        }];
331        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
332
333        // Same data again
334        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
335        assert_eq!(result.unchanged, 1);
336        assert_eq!(result.added, 0);
337        assert_eq!(result.updated, 0);
338    }
339
340    #[test]
341    fn test_sync_removes_deleted() {
342        let mut config = empty_config();
343        let section = make_section();
344
345        let remote = vec![ProviderHost {
346            server_id: "123".to_string(),
347            name: "web-1".to_string(),
348            ip: "1.2.3.4".to_string(),
349            tags: Vec::new(),
350        }];
351        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
352        assert_eq!(config.host_entries().len(), 1);
353
354        // Sync with empty remote list + remove_deleted
355        let result =
356            sync_provider(&mut config, &MockProvider, &[], &section, true, false);
357        assert_eq!(result.removed, 1);
358        assert_eq!(config.host_entries().len(), 0);
359    }
360
361    #[test]
362    fn test_sync_dry_run_no_mutations() {
363        let mut config = empty_config();
364        let section = make_section();
365
366        let remote = vec![ProviderHost {
367            server_id: "123".to_string(),
368            name: "web-1".to_string(),
369            ip: "1.2.3.4".to_string(),
370            tags: Vec::new(),
371        }];
372
373        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, true);
374        assert_eq!(result.added, 1);
375        assert_eq!(config.host_entries().len(), 0); // No actual changes
376    }
377
378    #[test]
379    fn test_sync_dedup_server_id_in_response() {
380        let mut config = empty_config();
381        let section = make_section();
382        let remote = vec![
383            ProviderHost {
384                server_id: "123".to_string(),
385                name: "web-1".to_string(),
386                ip: "1.2.3.4".to_string(),
387                tags: Vec::new(),
388            },
389            ProviderHost {
390                server_id: "123".to_string(),
391                name: "web-1-dup".to_string(),
392                ip: "5.6.7.8".to_string(),
393                tags: Vec::new(),
394            },
395        ];
396
397        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
398        assert_eq!(result.added, 1);
399        assert_eq!(config.host_entries().len(), 1);
400        assert_eq!(config.host_entries()[0].alias, "do-web-1");
401    }
402
403    #[test]
404    fn test_sync_no_duplicate_header_on_repeated_sync() {
405        let mut config = empty_config();
406        let section = make_section();
407
408        // First sync: adds header + host
409        let remote = vec![ProviderHost {
410            server_id: "123".to_string(),
411            name: "web-1".to_string(),
412            ip: "1.2.3.4".to_string(),
413            tags: Vec::new(),
414        }];
415        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
416
417        // Second sync: new host added at provider
418        let remote = vec![
419            ProviderHost {
420                server_id: "123".to_string(),
421                name: "web-1".to_string(),
422                ip: "1.2.3.4".to_string(),
423                tags: Vec::new(),
424            },
425            ProviderHost {
426                server_id: "456".to_string(),
427                name: "db-1".to_string(),
428                ip: "5.6.7.8".to_string(),
429                tags: Vec::new(),
430            },
431        ];
432        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
433
434        // Should have exactly one header
435        let header_count = config
436            .elements
437            .iter()
438            .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"))
439            .count();
440        assert_eq!(header_count, 1);
441        assert_eq!(config.host_entries().len(), 2);
442    }
443
444    #[test]
445    fn test_sync_removes_orphan_header() {
446        let mut config = empty_config();
447        let section = make_section();
448
449        // Add a host
450        let remote = vec![ProviderHost {
451            server_id: "123".to_string(),
452            name: "web-1".to_string(),
453            ip: "1.2.3.4".to_string(),
454            tags: Vec::new(),
455        }];
456        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
457
458        // Verify header exists
459        let has_header = config
460            .elements
461            .iter()
462            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
463        assert!(has_header);
464
465        // Remove all hosts (empty remote + remove_deleted)
466        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
467        assert_eq!(result.removed, 1);
468
469        // Header should be cleaned up
470        let has_header = config
471            .elements
472            .iter()
473            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
474        assert!(!has_header);
475    }
476
477    #[test]
478    fn test_sync_writes_provider_tags() {
479        let mut config = empty_config();
480        let section = make_section();
481        let remote = vec![ProviderHost {
482            server_id: "123".to_string(),
483            name: "web-1".to_string(),
484            ip: "1.2.3.4".to_string(),
485            tags: vec!["production".to_string(), "us-east".to_string()],
486        }];
487
488        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
489
490        let entries = config.host_entries();
491        assert_eq!(entries[0].tags, vec!["production", "us-east"]);
492    }
493
494    #[test]
495    fn test_sync_updates_changed_tags() {
496        let mut config = empty_config();
497        let section = make_section();
498
499        // First sync: add with tags
500        let remote = vec![ProviderHost {
501            server_id: "123".to_string(),
502            name: "web-1".to_string(),
503            ip: "1.2.3.4".to_string(),
504            tags: vec!["staging".to_string()],
505        }];
506        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
507        assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
508
509        // Second sync: tags changed (IP same)
510        let remote = vec![ProviderHost {
511            server_id: "123".to_string(),
512            name: "web-1".to_string(),
513            ip: "1.2.3.4".to_string(),
514            tags: vec!["production".to_string(), "us-east".to_string()],
515        }];
516        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
517        assert_eq!(result.updated, 1);
518        assert_eq!(
519            config.host_entries()[0].tags,
520            vec!["production", "us-east"]
521        );
522    }
523
524    #[test]
525    fn test_sync_combined_add_update_remove() {
526        let mut config = empty_config();
527        let section = make_section();
528
529        // First sync: add two hosts
530        let remote = vec![
531            ProviderHost {
532                server_id: "1".to_string(),
533                name: "web".to_string(),
534                ip: "1.1.1.1".to_string(),
535                tags: Vec::new(),
536            },
537            ProviderHost {
538                server_id: "2".to_string(),
539                name: "db".to_string(),
540                ip: "2.2.2.2".to_string(),
541                tags: Vec::new(),
542            },
543        ];
544        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
545        assert_eq!(config.host_entries().len(), 2);
546
547        // Second sync: host 1 IP changed, host 2 removed, host 3 added
548        let remote = vec![
549            ProviderHost {
550                server_id: "1".to_string(),
551                name: "web".to_string(),
552                ip: "9.9.9.9".to_string(),
553                tags: Vec::new(),
554            },
555            ProviderHost {
556                server_id: "3".to_string(),
557                name: "cache".to_string(),
558                ip: "3.3.3.3".to_string(),
559                tags: Vec::new(),
560            },
561        ];
562        let result =
563            sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
564        assert_eq!(result.updated, 1);
565        assert_eq!(result.added, 1);
566        assert_eq!(result.removed, 1);
567
568        let entries = config.host_entries();
569        assert_eq!(entries.len(), 2); // web (updated) + cache (added), db removed
570        assert_eq!(entries[0].alias, "do-web");
571        assert_eq!(entries[0].hostname, "9.9.9.9");
572        assert_eq!(entries[1].alias, "do-cache");
573    }
574
575    #[test]
576    fn test_sync_tag_order_insensitive() {
577        let mut config = empty_config();
578        let section = make_section();
579
580        // First sync: tags in one order
581        let remote = vec![ProviderHost {
582            server_id: "123".to_string(),
583            name: "web-1".to_string(),
584            ip: "1.2.3.4".to_string(),
585            tags: vec!["beta".to_string(), "alpha".to_string()],
586        }];
587        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
588
589        // Second sync: same tags, different order
590        let remote = vec![ProviderHost {
591            server_id: "123".to_string(),
592            name: "web-1".to_string(),
593            ip: "1.2.3.4".to_string(),
594            tags: vec!["alpha".to_string(), "beta".to_string()],
595        }];
596        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
597        assert_eq!(result.unchanged, 1);
598        assert_eq!(result.updated, 0);
599    }
600
601    fn config_with_include_provider_host() -> SshConfigFile {
602        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
603
604        // Build an included host block with provider marker
605        let content = "Host do-included\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:inc1\n";
606        let included_elements = SshConfigFile::parse_content(content);
607
608        SshConfigFile {
609            elements: vec![ConfigElement::Include(IncludeDirective {
610                raw_line: "Include conf.d/*".to_string(),
611                pattern: "conf.d/*".to_string(),
612                resolved_files: vec![IncludedFile {
613                    path: PathBuf::from("/tmp/included.conf"),
614                    elements: included_elements,
615                }],
616            })],
617            path: PathBuf::from("/tmp/test_config"),
618            crlf: false,
619        }
620    }
621
622    #[test]
623    fn test_sync_include_host_skips_update() {
624        let mut config = config_with_include_provider_host();
625        let section = make_section();
626
627        // Remote has same server with different IP — should NOT update included host
628        let remote = vec![ProviderHost {
629            server_id: "inc1".to_string(),
630            name: "included".to_string(),
631            ip: "9.9.9.9".to_string(),
632            tags: Vec::new(),
633        }];
634        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
635        assert_eq!(result.unchanged, 1);
636        assert_eq!(result.updated, 0);
637        assert_eq!(result.added, 0);
638
639        // Verify IP was NOT changed
640        let entries = config.host_entries();
641        let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
642        assert_eq!(included.hostname, "1.2.3.4");
643    }
644
645    #[test]
646    fn test_sync_include_host_skips_remove() {
647        let mut config = config_with_include_provider_host();
648        let section = make_section();
649
650        // Empty remote + remove_deleted — should NOT remove included host
651        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
652        assert_eq!(result.removed, 0);
653        assert_eq!(config.host_entries().len(), 1);
654    }
655
656    #[test]
657    fn test_sync_dry_run_remove_count() {
658        let mut config = empty_config();
659        let section = make_section();
660
661        // Add two hosts
662        let remote = vec![
663            ProviderHost {
664                server_id: "1".to_string(),
665                name: "web".to_string(),
666                ip: "1.1.1.1".to_string(),
667                tags: Vec::new(),
668            },
669            ProviderHost {
670                server_id: "2".to_string(),
671                name: "db".to_string(),
672                ip: "2.2.2.2".to_string(),
673                tags: Vec::new(),
674            },
675        ];
676        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
677        assert_eq!(config.host_entries().len(), 2);
678
679        // Dry-run remove with empty remote — should count but not mutate
680        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, true);
681        assert_eq!(result.removed, 2);
682        assert_eq!(config.host_entries().len(), 2); // Still there
683    }
684
685    #[test]
686    fn test_sync_tags_cleared() {
687        let mut config = empty_config();
688        let section = make_section();
689
690        // First sync: host with tags
691        let remote = vec![ProviderHost {
692            server_id: "123".to_string(),
693            name: "web-1".to_string(),
694            ip: "1.2.3.4".to_string(),
695            tags: vec!["production".to_string()],
696        }];
697        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
698        assert_eq!(config.host_entries()[0].tags, vec!["production"]);
699
700        // Second sync: tags removed
701        let remote = vec![ProviderHost {
702            server_id: "123".to_string(),
703            name: "web-1".to_string(),
704            ip: "1.2.3.4".to_string(),
705            tags: Vec::new(),
706        }];
707        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
708        assert_eq!(result.updated, 1);
709        assert!(config.host_entries()[0].tags.is_empty());
710    }
711
712    #[test]
713    fn test_sync_deduplicates_alias() {
714        let content = "Host do-web-1\n  HostName 10.0.0.1\n";
715        let mut config = SshConfigFile {
716            elements: SshConfigFile::parse_content(content),
717            path: PathBuf::from("/tmp/test_config"),
718            crlf: false,
719        };
720        let section = make_section();
721
722        let remote = vec![ProviderHost {
723            server_id: "999".to_string(),
724            name: "web-1".to_string(),
725            ip: "1.2.3.4".to_string(),
726            tags: Vec::new(),
727        }];
728
729        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
730
731        let entries = config.host_entries();
732        // Should have the original + a deduplicated one
733        assert_eq!(entries.len(), 2);
734        assert_eq!(entries[0].alias, "do-web-1");
735        assert_eq!(entries[1].alias, "do-web-1-2");
736    }
737}