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    /// Hosts marked stale (disappeared from provider but not hard-deleted).
16    pub stale: usize,
17    /// Alias renames: (old_alias, new_alias) pairs.
18    pub renames: Vec<(String, String)>,
19}
20
21/// Sanitize a server name into a valid SSH alias component.
22/// Lowercase, non-alphanumeric chars become hyphens, collapse consecutive hyphens.
23/// Falls back to "server" if the result would be empty (all-symbol/unicode names).
24fn sanitize_name(name: &str) -> String {
25    let mut result = String::new();
26    for c in name.chars() {
27        if c.is_ascii_alphanumeric() {
28            result.push(c.to_ascii_lowercase());
29        } else if !result.ends_with('-') {
30            result.push('-');
31        }
32    }
33    let trimmed = result.trim_matches('-').to_string();
34    if trimmed.is_empty() {
35        "server".to_string()
36    } else {
37        trimmed
38    }
39}
40
41/// Build an alias from prefix + sanitized name.
42/// If prefix is empty, uses just the sanitized name (no leading hyphen).
43fn build_alias(prefix: &str, sanitized: &str) -> String {
44    if prefix.is_empty() {
45        sanitized.to_string()
46    } else {
47        format!("{}-{}", prefix, sanitized)
48    }
49}
50
51/// Whether a metadata key is volatile (changes frequently without user action).
52/// Volatile keys are excluded from the sync diff comparison so that a status
53/// change alone does not trigger an SSH config rewrite. The value is still
54/// stored and displayed when the host is updated for other reasons.
55fn is_volatile_meta(key: &str) -> bool {
56    key == "status"
57}
58
59/// Sync hosts from a cloud provider into the SSH config.
60/// Provider tags are always stored in `# purple:provider_tags` and exactly
61/// mirror the remote state. User tags in `# purple:tags` are preserved.
62pub fn sync_provider(
63    config: &mut SshConfigFile,
64    provider: &dyn Provider,
65    remote_hosts: &[ProviderHost],
66    section: &ProviderSection,
67    remove_deleted: bool,
68    suppress_stale: bool,
69    dry_run: bool,
70) -> SyncResult {
71    let mut result = SyncResult::default();
72
73    // Build map of server_id -> alias.
74    //
75    // Bare config: claim EVERY marker for this provider regardless of how
76    // many `:`-segments it has. Some providers' server_ids contain colons
77    // (Proxmox uses `qemu:300`, OCI compartment IDs are path-like) and the
78    // marker `# purple:provider proxmox:qemu:300` is ambiguous in isolation
79    // - it could be a labeled marker (`proxmox:qemu`, server_id=`300`) or a
80    // legacy 2-segment marker with a colon-bearing server_id. Since a bare
81    // section is by definition the only config for its provider (we reject
82    // bare+labeled mix), it must own all of those hosts to avoid duplication.
83    //
84    // Labeled config: scope to its exact ProviderConfigId so two labeled
85    // configs of the same provider don't clobber each other's diff.
86    let existing = if section.id.label.is_none() {
87        // Bare config: use raw 2-segment interpretation so server_ids with
88        // colons (Proxmox `qemu:300`, OCI compartment paths) match correctly
89        // against the API response and don't get duplicated as "missing".
90        config.find_hosts_by_provider_raw(provider.name())
91    } else {
92        config.find_hosts_by_id(&section.id)
93    };
94    let mut existing_map: HashMap<String, String> = HashMap::new();
95    for (alias, server_id) in &existing {
96        existing_map
97            .entry(server_id.clone())
98            .or_insert_with(|| alias.clone());
99    }
100
101    // Build alias -> HostEntry lookup once (avoids quadratic host_entries() calls)
102    let entries_map: HashMap<String, HostEntry> = config
103        .host_entries()
104        .into_iter()
105        .map(|e| (e.alias.clone(), e))
106        .collect();
107
108    // Track which server IDs are still in the remote set (also deduplicates)
109    let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
110
111    // Add a group header when this provider has no TOP-LEVEL host yet. The
112    // header and every synced host live at top level (Include files are
113    // read-only), so this must agree with find_provider_insert_position, which
114    // is also top-level only. Using the include-aware find_hosts_by_provider
115    // here left a new host header-less when the provider's existing hosts lived
116    // solely in an Include. Group headers are still shared across labeled
117    // configs of one provider: the second labeled config finds the first
118    // config's top-level host and skips a duplicate header.
119    let mut needs_header = !dry_run
120        && config
121            .find_provider_insert_position(provider.name())
122            .is_none();
123
124    for remote in remote_hosts {
125        if !remote_ids.insert(remote.server_id.clone()) {
126            continue; // Skip duplicate server_id in same response
127        }
128
129        // Empty IP means the resource exists but has no resolvable address
130        // (e.g. stopped VM, no static IP). Count it in remote_ids so --remove
131        // won't delete it, but skip add/update. Still clear stale if the host
132        // reappeared (it exists in the provider, just has no IP).
133        if remote.ip.is_empty() {
134            if let Some(alias) = existing_map.get(&remote.server_id) {
135                if let Some(entry) = entries_map.get(alias.as_str()) {
136                    if entry.stale.is_some() {
137                        if !dry_run {
138                            let _ = config.clear_host_stale(alias);
139                        }
140                        result.updated += 1;
141                        continue;
142                    }
143                }
144                result.unchanged += 1;
145            }
146            continue;
147        }
148
149        if let Some(existing_alias) = existing_map.get(&remote.server_id) {
150            // Host exists, check if alias, IP or tags changed
151            if let Some(entry) = entries_map.get(existing_alias) {
152                // Included hosts are read-only; recognize them for dedup but skip mutations
153                if entry.source_file.is_some() {
154                    result.unchanged += 1;
155                    continue;
156                }
157
158                // Host reappeared: clear stale marking
159                let was_stale = entry.stale.is_some();
160                if was_stale && !dry_run {
161                    let _ = config.clear_host_stale(existing_alias);
162                }
163
164                // Check if alias prefix changed (e.g. "do" → "ocean")
165                let sanitized = sanitize_name(&remote.name);
166                let expected_alias = build_alias(&section.alias_prefix, &sanitized);
167                let alias_changed = *existing_alias != expected_alias;
168
169                let ip_changed = entry.hostname != remote.ip;
170                let meta_changed = {
171                    let mut local: Vec<(&str, &str)> = entry
172                        .provider_meta
173                        .iter()
174                        .filter(|(k, _)| !is_volatile_meta(k))
175                        .map(|(k, v)| (k.as_str(), v.as_str()))
176                        .collect();
177                    local.sort();
178                    let mut remote_m: Vec<(&str, &str)> = remote
179                        .metadata
180                        .iter()
181                        .filter(|(k, _)| !is_volatile_meta(k))
182                        .map(|(k, v)| (k.as_str(), v.as_str()))
183                        .collect();
184                    remote_m.sort();
185                    local != remote_m
186                };
187                let trimmed_remote: Vec<String> =
188                    remote.tags.iter().map(|t| t.trim().to_string()).collect();
189                let tags_changed = {
190                    // Compare provider_tags with remote (case-insensitive, sorted)
191                    let mut sorted_local: Vec<String> = entry
192                        .provider_tags
193                        .iter()
194                        .map(|t| t.trim().to_lowercase())
195                        .collect();
196                    sorted_local.sort();
197                    let mut sorted_remote: Vec<String> =
198                        trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
199                    sorted_remote.sort();
200                    sorted_local != sorted_remote
201                };
202                // First migration: host has old-format tags (# purple:tags) but
203                // no # purple:provider_tags comment yet. Tags need splitting.
204                let first_migration = !entry.has_provider_tags && !entry.tags.is_empty();
205
206                // After first migration: check if user tags overlap with provider tags
207                let user_tags_overlap = !first_migration
208                    && !trimmed_remote.is_empty()
209                    && entry.tags.iter().any(|t| {
210                        trimmed_remote
211                            .iter()
212                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
213                    });
214
215                if alias_changed
216                    || ip_changed
217                    || tags_changed
218                    || meta_changed
219                    || user_tags_overlap
220                    || first_migration
221                    || was_stale
222                {
223                    if dry_run {
224                        result.updated += 1;
225                    } else {
226                        // Compute the final alias (dedup handles collisions,
227                        // excluding the host being renamed so it doesn't collide with itself)
228                        let new_alias = if alias_changed {
229                            config
230                                .deduplicate_alias_excluding(&expected_alias, Some(existing_alias))
231                        } else {
232                            existing_alias.clone()
233                        };
234                        // Re-evaluate: dedup may resolve back to the current alias
235                        let alias_changed = new_alias != *existing_alias;
236
237                        if alias_changed
238                            || ip_changed
239                            || tags_changed
240                            || meta_changed
241                            || user_tags_overlap
242                            || first_migration
243                            || was_stale
244                        {
245                            if alias_changed || ip_changed {
246                                let updated = HostEntry {
247                                    alias: new_alias.clone(),
248                                    hostname: remote.ip.clone(),
249                                    ..entry.clone()
250                                };
251                                config.update_host(existing_alias, &updated);
252                            }
253                            // Tags lookup uses the new alias after rename
254                            let tags_alias = if alias_changed {
255                                &new_alias
256                            } else {
257                                existing_alias
258                            };
259                            if tags_changed || first_migration {
260                                let _ = config.set_host_provider_tags(tags_alias, &trimmed_remote);
261                            }
262                            // Migration cleanup
263                            if first_migration {
264                                // First migration: old # purple:tags had both provider
265                                // and user tags mixed. Keep only tags NOT in remote
266                                // (those must be user-added). Provider tags move to
267                                // # purple:provider_tags.
268                                let user_only: Vec<String> = entry
269                                    .tags
270                                    .iter()
271                                    .filter(|t| {
272                                        !trimmed_remote
273                                            .iter()
274                                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
275                                    })
276                                    .cloned()
277                                    .collect();
278                                let _ = config.set_host_tags(tags_alias, &user_only);
279                            } else if tags_changed || user_tags_overlap {
280                                // Ongoing: remove user tags that overlap with provider tags
281                                let cleaned: Vec<String> = entry
282                                    .tags
283                                    .iter()
284                                    .filter(|t| {
285                                        !trimmed_remote
286                                            .iter()
287                                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
288                                    })
289                                    .cloned()
290                                    .collect();
291                                if cleaned.len() != entry.tags.len() {
292                                    let _ = config.set_host_tags(tags_alias, &cleaned);
293                                }
294                            }
295                            // Update provider marker with new alias.
296                            // Use the section's full id so labeled configs
297                            // emit 3-segment markers.
298                            if alias_changed {
299                                let _ = config.set_host_provider_id(
300                                    &new_alias,
301                                    &section.id,
302                                    &remote.server_id,
303                                );
304                                result
305                                    .renames
306                                    .push((existing_alias.clone(), new_alias.clone()));
307                            }
308                            // Update metadata
309                            if meta_changed {
310                                let _ = config.set_host_meta(tags_alias, &remote.metadata);
311                            }
312                            result.updated += 1;
313                        } else {
314                            result.unchanged += 1;
315                        }
316                    }
317                } else {
318                    result.unchanged += 1;
319                }
320            } else {
321                result.unchanged += 1;
322            }
323        } else {
324            // New host
325            let sanitized = sanitize_name(&remote.name);
326            let base_alias = build_alias(&section.alias_prefix, &sanitized);
327            let alias = if dry_run {
328                base_alias
329            } else {
330                config.deduplicate_alias(&base_alias)
331            };
332
333            if !dry_run {
334                // Add group header before the very first host for this provider
335                let wrote_header = needs_header;
336                if needs_header {
337                    if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
338                        config
339                            .elements
340                            .push(ConfigElement::GlobalLine(String::new()));
341                    }
342                    config.elements.push(ConfigElement::GlobalLine(format!(
343                        "# purple:group {}",
344                        super::provider_display_name(provider.name())
345                    )));
346                    needs_header = false;
347                }
348
349                let entry = HostEntry {
350                    alias: alias.clone(),
351                    hostname: remote.ip.clone(),
352                    user: section.user.clone(),
353                    identity_file: section.identity_file.clone(),
354                    provider: Some(provider.name().to_string()),
355                    ..Default::default()
356                };
357
358                let block = SshConfigFile::entry_to_block(&entry);
359
360                // Insert adjacent to existing provider hosts (keeps groups together).
361                // For the very first host (wrote_header), fall through to push at end.
362                let insert_pos = if !wrote_header {
363                    config.find_provider_insert_position(provider.name())
364                } else {
365                    None
366                };
367
368                if let Some(pos) = insert_pos {
369                    // Mirror add_host: guarantee a blank line BEFORE the new
370                    // block (so consecutive synced hosts never glue together)
371                    // and AFTER it (so it never runs into the next group header
372                    // or host block).
373                    let mut idx = pos;
374                    let needs_blank_before = idx > 0
375                        && !matches!(
376                            config.elements.get(idx - 1),
377                            Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
378                        );
379                    if needs_blank_before {
380                        config
381                            .elements
382                            .insert(idx, ConfigElement::GlobalLine(String::new()));
383                        idx += 1;
384                    }
385                    config.elements.insert(idx, ConfigElement::HostBlock(block));
386                    let after = idx + 1;
387                    let needs_trailing_blank = config.elements.get(after).is_some_and(
388                        |e| !matches!(e, ConfigElement::GlobalLine(line) if line.trim().is_empty()),
389                    );
390                    if needs_trailing_blank {
391                        config
392                            .elements
393                            .insert(after, ConfigElement::GlobalLine(String::new()));
394                    }
395                } else {
396                    // No existing group or first host: append at end with separator
397                    if !wrote_header
398                        && !config.elements.is_empty()
399                        && !config.last_element_has_trailing_blank()
400                    {
401                        config
402                            .elements
403                            .push(ConfigElement::GlobalLine(String::new()));
404                    }
405                    config.elements.push(ConfigElement::HostBlock(block));
406                }
407
408                let _ = config.set_host_provider_id(&alias, &section.id, &remote.server_id);
409                if !remote.tags.is_empty() {
410                    let _ = config.set_host_provider_tags(&alias, &remote.tags);
411                }
412                if !remote.metadata.is_empty() {
413                    let _ = config.set_host_meta(&alias, &remote.metadata);
414                }
415            }
416
417            result.added += 1;
418        }
419    }
420
421    // Remove deleted hosts (skip included hosts which are read-only)
422    if remove_deleted && !dry_run {
423        let to_remove: Vec<String> = existing_map
424            .iter()
425            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
426            .filter(|(_, alias)| {
427                entries_map
428                    .get(alias.as_str())
429                    .is_none_or(|e| e.source_file.is_none())
430            })
431            .map(|(_, alias)| alias.clone())
432            .collect();
433        for alias in &to_remove {
434            config.delete_host(alias);
435        }
436        result.removed = to_remove.len();
437
438        // Clean up orphan provider header if all hosts for this provider were removed
439        if config.find_hosts_by_provider(provider.name()).is_empty() {
440            let header_text = format!(
441                "# purple:group {}",
442                super::provider_display_name(provider.name())
443            );
444            config
445                .elements
446                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
447        }
448    } else if remove_deleted {
449        result.removed = existing_map
450            .iter()
451            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
452            .filter(|(_, alias)| {
453                entries_map
454                    .get(alias.as_str())
455                    .is_none_or(|e| e.source_file.is_none())
456            })
457            .count();
458    }
459
460    // Soft-delete: mark disappeared hosts as stale (when not hard-deleting)
461    if !remove_deleted && !suppress_stale {
462        let to_stale: Vec<String> = existing_map
463            .iter()
464            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
465            .filter(|(_, alias)| {
466                entries_map
467                    .get(alias.as_str())
468                    .is_none_or(|e| e.source_file.is_none())
469            })
470            .map(|(_, alias)| alias.clone())
471            .collect();
472        if !dry_run {
473            let now = std::time::SystemTime::now()
474                .duration_since(std::time::UNIX_EPOCH)
475                .unwrap_or_default()
476                .as_secs();
477            for alias in &to_stale {
478                // Preserve original timestamp if already stale
479                if entries_map
480                    .get(alias.as_str())
481                    .is_none_or(|e| e.stale.is_none())
482                {
483                    let _ = config.set_host_stale(alias, now);
484                }
485            }
486        }
487        result.stale = to_stale.len();
488    }
489
490    result
491}
492
493#[cfg(test)]
494#[path = "sync_tests.rs"]
495mod tests;