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(§ion.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(§ion.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, §ion.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, §ion.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 §ion.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(§ion.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, §ion.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, §ion.user);
636 }
637 if !section.identity_file.is_empty() {
638 let _ = config.set_host_provider_key(&alias, §ion.identity_file);
639 }
640}
641
642#[cfg(test)]
643#[path = "sync_tests.rs"]
644mod tests;