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                    && entry.stale.is_some()
137                {
138                    if !dry_run {
139                        let _ = config.clear_host_stale(alias);
140                    }
141                    result.updated += 1;
142                    continue;
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                // Port and provider-managed directives (ProxyCommand and the
171                // like) mirror the remote side, so a drift on either counts.
172                let port_changed = remote.port.is_some_and(|p| p != entry.port);
173                let directives_changed = remote.directives.iter().any(|(k, v)| {
174                    config.host_directive(existing_alias, k).as_deref() != Some(v.as_str())
175                }) || remote.retract_directives.iter().any(|rule| {
176                    config
177                        .host_directive(existing_alias, &rule.key)
178                        .is_some_and(|local| rule.claims(&local))
179                });
180                let meta_changed = {
181                    let mut local: Vec<(&str, &str)> = entry
182                        .provider_meta
183                        .iter()
184                        .filter(|(k, _)| !is_volatile_meta(k))
185                        .map(|(k, v)| (k.as_str(), v.as_str()))
186                        .collect();
187                    local.sort();
188                    let mut remote_m: Vec<(&str, &str)> = remote
189                        .metadata
190                        .iter()
191                        .filter(|(k, _)| !is_volatile_meta(k))
192                        .map(|(k, v)| (k.as_str(), v.as_str()))
193                        .collect();
194                    remote_m.sort();
195                    local != remote_m
196                };
197                let trimmed_remote: Vec<String> =
198                    remote.tags.iter().map(|t| t.trim().to_string()).collect();
199                let tags_changed = {
200                    // Compare provider_tags with remote (case-insensitive, sorted)
201                    let mut sorted_local: Vec<String> = entry
202                        .provider_tags
203                        .iter()
204                        .map(|t| t.trim().to_lowercase())
205                        .collect();
206                    sorted_local.sort();
207                    let mut sorted_remote: Vec<String> =
208                        trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
209                    sorted_remote.sort();
210                    sorted_local != sorted_remote
211                };
212                // First migration: host has old-format tags (# purple:tags) but
213                // no # purple:provider_tags comment yet. Tags need splitting.
214                let first_migration = !entry.has_provider_tags && !entry.tags.is_empty();
215
216                // After first migration: check if user tags overlap with provider tags
217                let user_tags_overlap = !first_migration
218                    && !trimmed_remote.is_empty()
219                    && entry.tags.iter().any(|t| {
220                        trimmed_remote
221                            .iter()
222                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
223                    });
224
225                // User and identity file come from the section config, not the
226                // remote API. Track the last-synced value per host in
227                // # purple:provider_user / # purple:provider_key so a change to
228                // the provider config propagates to existing hosts, while a
229                // manual per-host override (host value != marker) is preserved.
230                // A host with no marker yet (synced before this existed) is
231                // treated as managed and adopts the current provider value once.
232                // Only propagate when BOTH the provider value and the host's
233                // current value are non-empty: we never strip a directive by
234                // clearing the field, and we never inject one into a host that
235                // has none (a fresh sync sets it at creation; an existing host
236                // without the directive is left as the user left it).
237                let user_changed = !section.user.is_empty()
238                    && !entry.user.is_empty()
239                    && entry
240                        .provider_user
241                        .as_deref()
242                        .is_none_or(|m| m == entry.user)
243                    && entry.user != section.user;
244                let key_changed = !section.identity_file.is_empty()
245                    && !entry.identity_file.is_empty()
246                    && entry
247                        .provider_key
248                        .as_deref()
249                        .is_none_or(|m| m == entry.identity_file)
250                    && entry.identity_file != section.identity_file;
251
252                // Establish the tracking baseline on a legacy provider host
253                // (synced before these markers existed) whose value already
254                // matches the provider config, so a manual override made AFTER
255                // this sync is detectable. Without it such a host stays
256                // marker-less and a later override would be clobbered on the
257                // next provider change. Records the host's current value, so it
258                // never changes a directive. `user_changed` (divergence) takes
259                // precedence and writes the marker itself.
260                let user_needs_baseline = !section.user.is_empty()
261                    && !entry.user.is_empty()
262                    && entry.provider_user.is_none()
263                    && !user_changed;
264                let key_needs_baseline = !section.identity_file.is_empty()
265                    && !entry.identity_file.is_empty()
266                    && entry.provider_key.is_none()
267                    && !key_changed;
268
269                if alias_changed
270                    || ip_changed
271                    || port_changed
272                    || directives_changed
273                    || tags_changed
274                    || meta_changed
275                    || user_tags_overlap
276                    || first_migration
277                    || was_stale
278                    || user_changed
279                    || key_changed
280                    || user_needs_baseline
281                    || key_needs_baseline
282                {
283                    if dry_run {
284                        result.updated += 1;
285                    } else {
286                        // Compute the final alias (dedup handles collisions,
287                        // excluding the host being renamed so it doesn't collide with itself)
288                        let new_alias = if alias_changed {
289                            config
290                                .deduplicate_alias_excluding(&expected_alias, Some(existing_alias))
291                        } else {
292                            existing_alias.clone()
293                        };
294                        // Re-evaluate: dedup may resolve back to the current alias
295                        let alias_changed = new_alias != *existing_alias;
296
297                        if alias_changed
298                            || ip_changed
299                            || port_changed
300                            || directives_changed
301                            || tags_changed
302                            || meta_changed
303                            || user_tags_overlap
304                            || first_migration
305                            || was_stale
306                            || user_changed
307                            || key_changed
308                            || user_needs_baseline
309                            || key_needs_baseline
310                        {
311                            if alias_changed
312                                || ip_changed
313                                || port_changed
314                                || user_changed
315                                || key_changed
316                            {
317                                let updated = HostEntry {
318                                    alias: new_alias.clone(),
319                                    hostname: remote.ip.clone(),
320                                    port: remote.port.unwrap_or(entry.port),
321                                    user: if user_changed {
322                                        section.user.clone()
323                                    } else {
324                                        entry.user.clone()
325                                    },
326                                    identity_file: if key_changed {
327                                        section.identity_file.clone()
328                                    } else {
329                                        entry.identity_file.clone()
330                                    },
331                                    ..entry.clone()
332                                };
333                                config.update_host(existing_alias, &updated);
334                            }
335                            // Tags lookup uses the new alias after rename
336                            let tags_alias = if alias_changed {
337                                &new_alias
338                            } else {
339                                existing_alias
340                            };
341                            // Record the last-synced provider value so a later
342                            // manual edit is detectable and the next provider
343                            // change also propagates. On divergence the marker
344                            // takes the new provider value; on a pure baseline
345                            // it records the host's current (matching) value.
346                            if user_changed {
347                                let _ = config.set_host_provider_user(tags_alias, &section.user);
348                            } else if user_needs_baseline {
349                                let _ = config.set_host_provider_user(tags_alias, &entry.user);
350                            }
351                            if key_changed {
352                                let _ = config
353                                    .set_host_provider_key(tags_alias, &section.identity_file);
354                            } else if key_needs_baseline {
355                                let _ =
356                                    config.set_host_provider_key(tags_alias, &entry.identity_file);
357                            }
358                            if tags_changed || first_migration {
359                                let _ = config.set_host_provider_tags(tags_alias, &trimmed_remote);
360                            }
361                            // Migration cleanup
362                            if first_migration {
363                                // First migration: old # purple:tags had both provider
364                                // and user tags mixed. Keep only tags NOT in remote
365                                // (those must be user-added). Provider tags move to
366                                // # purple:provider_tags.
367                                let user_only: Vec<String> = entry
368                                    .tags
369                                    .iter()
370                                    .filter(|t| {
371                                        !trimmed_remote
372                                            .iter()
373                                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
374                                    })
375                                    .cloned()
376                                    .collect();
377                                let _ = config.set_host_tags(tags_alias, &user_only);
378                            } else if tags_changed || user_tags_overlap {
379                                // Ongoing: remove user tags that overlap with provider tags
380                                let cleaned: Vec<String> = entry
381                                    .tags
382                                    .iter()
383                                    .filter(|t| {
384                                        !trimmed_remote
385                                            .iter()
386                                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
387                                    })
388                                    .cloned()
389                                    .collect();
390                                if cleaned.len() != entry.tags.len() {
391                                    let _ = config.set_host_tags(tags_alias, &cleaned);
392                                }
393                            }
394                            // Update provider marker with new alias.
395                            // Use the section's full id so labeled configs
396                            // emit 3-segment markers.
397                            if alias_changed {
398                                let _ = config.set_host_provider_id(
399                                    &new_alias,
400                                    &section.id,
401                                    &remote.server_id,
402                                );
403                                result
404                                    .renames
405                                    .push((existing_alias.clone(), new_alias.clone()));
406                            }
407                            // Update metadata
408                            if meta_changed {
409                                let _ = config.set_host_meta(tags_alias, &remote.metadata);
410                            }
411                            if directives_changed {
412                                for (key, value) in &remote.directives {
413                                    let _ = config.set_host_directive(tags_alias, key, value);
414                                }
415                                // An empty value removes the directive, and
416                                // only a value the provider claims as its own
417                                // is removed, so a line the user wrote or
418                                // edited survives. Logged on the write's own
419                                // result: this is a destructive edit to the
420                                // user's config that leaves no other trace,
421                                // and a multi-alias block refuses the write.
422                                for rule in &remote.retract_directives {
423                                    // A key the provider also writes above is
424                                    // one it wants, so a rule that would take
425                                    // that line straight back out is skipped.
426                                    let also_written =
427                                        remote.directives.iter().any(|(key, _)| key == &rule.key);
428                                    let claimed = !also_written
429                                        && config
430                                            .host_directive(tags_alias, &rule.key)
431                                            .is_some_and(|local| rule.claims(&local));
432                                    if claimed
433                                        && config.set_host_directive(tags_alias, &rule.key, "")
434                                    {
435                                        log::debug!(
436                                            "[purple] sync withdrew {} from '{}'",
437                                            rule.key,
438                                            tags_alias
439                                        );
440                                    }
441                                }
442                            }
443                            result.updated += 1;
444                        } else {
445                            result.unchanged += 1;
446                        }
447                    }
448                } else {
449                    result.unchanged += 1;
450                }
451            } else {
452                result.unchanged += 1;
453            }
454        } else {
455            // New host
456            if !dry_run {
457                add_remote_host(config, provider, section, remote, &mut needs_header);
458            }
459            result.added += 1;
460        }
461    }
462
463    // Remove deleted hosts (skip included hosts which are read-only)
464    if remove_deleted && !dry_run {
465        let to_remove: Vec<String> = existing_map
466            .iter()
467            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
468            .filter(|(_, alias)| {
469                entries_map
470                    .get(alias.as_str())
471                    .is_none_or(|e| e.source_file.is_none())
472            })
473            .map(|(_, alias)| alias.clone())
474            .collect();
475        for alias in &to_remove {
476            config.delete_host(alias);
477        }
478        result.removed = to_remove.len();
479
480        // Clean up orphan provider header if all hosts for this provider were removed
481        if config.find_hosts_by_provider(provider.name()).is_empty() {
482            let header_text = format!(
483                "# purple:group {}",
484                super::provider_display_name(provider.name())
485            );
486            config
487                .elements
488                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
489        }
490    } else if remove_deleted {
491        result.removed = existing_map
492            .iter()
493            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
494            .filter(|(_, alias)| {
495                entries_map
496                    .get(alias.as_str())
497                    .is_none_or(|e| e.source_file.is_none())
498            })
499            .count();
500    }
501
502    // Soft-delete: mark disappeared hosts as stale (when not hard-deleting)
503    if !remove_deleted && !suppress_stale {
504        let to_stale: Vec<String> = existing_map
505            .iter()
506            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
507            .filter(|(_, alias)| {
508                entries_map
509                    .get(alias.as_str())
510                    .is_none_or(|e| e.source_file.is_none())
511            })
512            .map(|(_, alias)| alias.clone())
513            .collect();
514        if !dry_run {
515            let now = std::time::SystemTime::now()
516                .duration_since(std::time::UNIX_EPOCH)
517                .unwrap_or_default()
518                .as_secs();
519            for alias in &to_stale {
520                // Preserve original timestamp if already stale
521                if entries_map
522                    .get(alias.as_str())
523                    .is_none_or(|e| e.stale.is_none())
524                {
525                    let _ = config.set_host_stale(alias, now);
526                }
527            }
528        }
529        result.stale = to_stale.len();
530    }
531
532    result
533}
534
535/// Write a brand-new host block for `remote`: alias from the section prefix,
536/// the group header on the very first host, then the provider markers,
537/// tags, metadata and managed directives.
538fn add_remote_host(
539    config: &mut SshConfigFile,
540    provider: &dyn Provider,
541    section: &ProviderSection,
542    remote: &ProviderHost,
543    needs_header: &mut bool,
544) {
545    let sanitized = sanitize_name(&remote.name);
546    let base_alias = build_alias(&section.alias_prefix, &sanitized);
547    let alias = config.deduplicate_alias(&base_alias);
548
549    // Add group header before the very first host for this provider
550    let wrote_header = *needs_header;
551    if *needs_header {
552        if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
553            config
554                .elements
555                .push(ConfigElement::GlobalLine(String::new()));
556        }
557        config.elements.push(ConfigElement::GlobalLine(format!(
558            "# purple:group {}",
559            super::provider_display_name(provider.name())
560        )));
561        *needs_header = false;
562    }
563
564    let entry = HostEntry {
565        alias: alias.clone(),
566        hostname: remote.ip.clone(),
567        port: remote.port.unwrap_or(22),
568        user: section.user.clone(),
569        identity_file: section.identity_file.clone(),
570        provider: Some(provider.name().to_string()),
571        ..Default::default()
572    };
573
574    let block = SshConfigFile::entry_to_block(&entry);
575
576    // Insert adjacent to existing provider hosts (keeps groups together).
577    // For the very first host (wrote_header), fall through to push at end.
578    let insert_pos = if !wrote_header {
579        config.find_provider_insert_position(provider.name())
580    } else {
581        None
582    };
583
584    if let Some(pos) = insert_pos {
585        // Mirror add_host: guarantee a blank line BEFORE the new
586        // block (so consecutive synced hosts never glue together)
587        // and AFTER it (so it never runs into the next group header
588        // or host block).
589        let mut idx = pos;
590        let needs_blank_before = idx > 0
591            && !matches!(
592                config.elements.get(idx - 1),
593                Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
594            );
595        if needs_blank_before {
596            config
597                .elements
598                .insert(idx, ConfigElement::GlobalLine(String::new()));
599            idx += 1;
600        }
601        config.elements.insert(idx, ConfigElement::HostBlock(block));
602        let after = idx + 1;
603        let needs_trailing_blank = config.elements.get(after).is_some_and(
604            |e| !matches!(e, ConfigElement::GlobalLine(line) if line.trim().is_empty()),
605        );
606        if needs_trailing_blank {
607            config
608                .elements
609                .insert(after, ConfigElement::GlobalLine(String::new()));
610        }
611    } else {
612        // No existing group or first host: append at end with separator
613        if !wrote_header && !config.elements.is_empty() && !config.last_element_has_trailing_blank()
614        {
615            config
616                .elements
617                .push(ConfigElement::GlobalLine(String::new()));
618        }
619        config.elements.push(ConfigElement::HostBlock(block));
620    }
621
622    for (key, value) in &remote.directives {
623        let _ = config.set_host_directive(&alias, key, value);
624    }
625    let _ = config.set_host_provider_id(&alias, &section.id, &remote.server_id);
626    if !remote.tags.is_empty() {
627        let _ = config.set_host_provider_tags(&alias, &remote.tags);
628    }
629    if !remote.metadata.is_empty() {
630        let _ = config.set_host_meta(&alias, &remote.metadata);
631    }
632    // Baseline the last-synced provider connection values so a
633    // later manual override is detectable on the next sync.
634    if !section.user.is_empty() {
635        let _ = config.set_host_provider_user(&alias, &section.user);
636    }
637    if !section.identity_file.is_empty() {
638        let _ = config.set_host_provider_key(&alias, &section.identity_file);
639    }
640}
641
642#[cfg(test)]
643#[path = "sync_tests.rs"]
644mod tests;